C++实现即时通信软件

1.1 实验内容

本实验使用 C++ 实现一个具备服务器端和客户端的即时通信聊天室。

这个项目会学习C++网络开发的基本概念,同时可以熟悉Linux下的C++程序编译方法及简单的Makefile编写。

1.2 实验知识点

  • C++语言基本语法
  • 基本的Makefile
  • C++面向对象程序设计
  • epoll 网络编程

1.3 实验环境

  • g++
  • Xfce 终端

2.1 需求分析

在这个聊天室软件中我们有下面两个程序:

  1. 服务器:能够接受新的客户端连接,并将每个客户端发过来的消息发给所有其他的客户端
  2. 客户端:能够连接服务器,并向服务器发送消息,同时接收服务器发过来的任何消息

​ 这个需求是最简单的聊天室需求,我们目前只实现了群聊,未来大家可以扩展到单独的两个客户端之间的私聊。为了降低学习的难度,突出重点,我们尽量将代码修改的简单,项目中复杂功能都去掉了,**线程池、多线程编程、超时重传、确认收包等等都不会涉及。**让大家真正了解C/S模型,以及epoll的使用。

2.2 抽象与细化

根据上面的需求分析,设计所需的类。

需求中的角色非常简单,同时功能也很简单,所以我们只需要根据功能角色设计客户端类和服务端类。

其中客户端类我们需要支持下面几个功能:

  1. 连接服务器
  2. 支持用户输入聊天消息,发送消息给服务器
  3. 接收并显示服务器的消息
  4. 退出连接

针对上述需求,客户端的实现需要两个进程分别支持下面的功能:

子进程的功能:

  1. 等待用户输入聊天信息
  2. 将聊天信息写到管道(pipe),并发送给父进程

父进程的功能:

  1. 使用epoll机制接受服务端发来的信息,并显示给用户,使用户看到其他用户的聊天信息
  2. 将子进程发给的聊天信息从管道(pipe)中读取, 并发送给服务端

服务端类需要支持:

  1. 支持多个客户端接入,实现聊天室基本功能
  2. 启动服务建立监听端口等待客户端连接
  3. 使用epoll机制实现并发,增加效率
  4. 客户端连接时发送欢迎消息并存储连接记录
  5. 客户端发送消息时广播给其他所有客户端
  6. 客户端请求退出时对连接信息进行清理

如果实现这两个类,我们需要先学习一些网络编程的基础知识。

2.3 C/S模型

​ 首先介绍下模型。服务端和客户端采用经典的C/S模型,并且使用TCP连接,模型如下:

解释如下:

服务器端:

  1. socket()创建监听Socket
  2. bind()绑定服务器端口
  3. listen()监听客户端连接
  4. accept()接受连接
  5. recv/send接收及发送数据
  6. close()关闭socket

客户端:

  1. socket()创建监听Socket
  2. connect()连接服务器
  3. recv/send接收及发送数据
  4. close()关闭socket

2.3.1 TCP服务端通信的常规步骤

  1. 使用socket()创建TCP套接字(socket)
  2. 将创建的套接字绑定到一个本地地址和端口上(Bind)
  3. 将套接字设为监听模式,准备接收客户端请求(listen)
  4. 等待客户请求到来: 当请求到来后,接受连接请求,返回一个对应于此次连接的新的套接字(accept)
  5. 用accept返回的套接字和客户端进行通信(使用write()/send()或send()/recv() )
  6. 返回,等待另一个客户请求
  7. 关闭套接字

服务端流程示例代码:

//Server.cpp代码(通信模块):
//服务端地址 ip地址 + 端口号
struct sockaddr_in serverAddr;
serverAddr.sin_family = PF_INET;
serverAddr.sin_port = htons(SERVER_PORT);
serverAddr.sin_addr.s_addr = inet_addr(SERVER_HOST);

//服务端创建监听socket
int listener = socket(PF_INET, SOCK_STREAM, 0);
if(listener < 0) { perror("listener"); exit(-1);}
printf("listen socket created \n");

//将服务端地址与监听socket绑定
if( bind(listener, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
    perror("bind error");
    exit(-1);
}
//开始监听
int ret = listen(listener, 5);
if(ret < 0) { perror("listen error"); exit(-1);}
printf("Start to listen: %s\n", SERVER_HOST);

2.3.2 TCP客户端通信的常规步骤

  1. 创建套接字(socket)
  2. 使用connect()建立到达服务器的连接(connect)
  3. 客户端进行通信(使用write()/send()或send()/recv())
  4. 使用close()关闭客户连接

客户端流程示例代码:

//Client.cpp代码(通信模块):
//客户要连接的服务端地址( ip地址 + 端口号)
struct sockaddr_in serverAddr;
serverAddr.sin_family = PF_INET;
serverAddr.sin_port = htons(SERVER_PORT);
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);

// 创建套接字(socket)
int sock = socket(PF_INET, SOCK_STREAM, 0);
if(sock < 0) { perror("sock error"); exit(-1); }
//向服务器发出连接请求(connect)
if(connect(sock, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
    perror("connect error");
    exit(-1);
}

客户端如何实现管道之间的通信,以及与服务端之间的通信,在后面会详细介绍。

完成这步后, 我们需要学习下几个比较重要的概念。

2.4 基本技术介绍

2.4.1 阻塞与非阻塞socket

通常的,对一个文件描述符指定的文件或设备, 有两种工作方式: 阻塞与非阻塞方式。

  1. 阻塞方式是指: 当试图对该文件描述符进行读写时,如果当时没有数据可读,或者暂时不可写,程序就进入等待状态,直到有东西可读或者可写为止。
  2. 非阻塞方式是指: 如果没有数据可读,或者不可写,读写函数马上返回,而不会等待。

​ 举个例子来说,比如说小明去找一个女神聊天,女神却不在。如果小明舍不得走,只能在女神大门口死等着,当然小明可以休息。当女神来了,她会把你唤醒(囧,因为挡着她门了),这就是阻塞方式。如果小明发现女神不在,立即离开,以后每隔十分钟回来看一下(采用轮询方式),不在的话仍然立即离开,这就是非阻塞方式,在他离开的十分钟内可以干别的事情。

​ 阻塞方式和非阻塞方式唯一的区别: 是否立即返回。本项目采用更高效的做法,所以应该将socket设置为非阻塞方式。这样能充分利用服务器资源,效率得到了很大提高。

非阻塞设置方式示例代码:

//将文件描述符设置为非阻塞方式(利用fcntl函数)
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)| O_NONBLOCK);

2.4.2 epoll

​ 前面介绍了阻塞和非阻塞方式,现在该介绍下epoll机制了。epoll真的是一个特别重要的概念,互联网公司面试后台开发,或者系统开发等相关职位都会问epoll机制。当服务端的在线人数越来越多,会导致系统资源吃紧,I/O效率越来越慢,这时候就应该考虑epoll了。epoll是Linux内核为处理大批句柄而作改进的poll,是Linux特有的I/O函数。其特点如下:

  1. epoll是Linux下多路复用IO接口select/poll的增强版本。其实现和使用方式与select/poll有很多不同,epoll通过一组函数来完成有关任务,而不是一个函数。
  2. epoll之所以高效,是因为**epoll将用户关心的文件描述符放到内核里的一个事件表中,**而不是像select/poll每次调用都需要重复传入文件描述符集或事件集。比如当一个事件发生(比如说读事件),epoll无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入就绪队列的描述符集合就行了。
  3. epoll有两种工作方式,LT(level triggered):水平触发和ET(edge-triggered):边沿触发。LT是select/poll使用的触发方式,比较低效;而ET是epoll的高速工作方式(本项目使用epoll的ET方式)。

​ 通俗理解就是,比如说有一堆女孩,有的很漂亮,有的很凤姐。现在你想找漂亮的女孩聊天,LT就是你需要把这一堆女孩全都看一遍,才可以找到其中的漂亮的(就绪事件);而ET是你的小弟(内核)将N个漂亮的女孩编号告诉你,你直接去看就好,所以epoll很高效。另外,还记得小明找女神聊天的例子吗?采用非阻塞方式,小明还需要每隔十分钟回来看一下(select);如果小明有小弟(内核)帮他守在大门口,女神回来了,小弟会主动打电话,告诉小明女神回来了,快来处理吧!这就是epoll。

epoll 共3个函数, 如下:

创建一个epoll句柄:

// 创建一个epoll句柄,参数size用来告诉内核监听的数目,size为epoll所支持的最大句柄数,我们这里暂时忽略内核升级的问题。
int epoll_create(int size)

epoll事件注册函数:

/*
函数功能:epoll事件注册函数
  参数epfd为epoll的句柄,即epoll_create返回值
  参数op表示动作,用3个宏来表示:
    EPOLL_CTL_ADD(注册新的fd到epfd), 
      EPOLL_CTL_MOD(修改已经注册的fd的监听事件),
    EPOLL_CTL_DEL(从epfd删除一个fd);
    其中参数fd为需要监听的标示符;
  参数event告诉内核需要监听的事件,event的结构如下:
struct epoll_event {
  __uint32_t events; //Epoll events
  epoll_data_t data; //User data variable
};
其中介绍events是宏的集合,本项目主要使用EPOLLIN(表示对应的文件描述符可以读,即读事件发生),其他宏类型,可以google之!
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

等待事件的产生:

// 等待事件的产生,函数返回需要处理的事件数目(该数目是就绪事件的数目,就是前面所说漂亮女孩的个数N)
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)

因此服务端使用epoll的时候,步骤如下:

  1. 调用epoll_create函数在Linux内核中创建一个事件表;
  2. 然后将文件描述符(监听套接字listener)添加到所创建的事件表中;
  3. 在主循环中,调用epoll_wait等待返回就绪的文件描述符集合;
  4. 分别处理就绪的事件集合,本项目中一共有两类事件:新用户连接事件和用户发来消息事件(epoll还有很多其他事件,本项目为简洁明了,不介绍)。

下面介绍如何将一个socket添加到事件表中,如下:

//将文件描述符fd添加到epollfd标示的内核事件表中, 并注册EPOLLIN和EPOOLET事件,EPOLLIN是数据可读事件;EPOOLET表明是ET工作方式。最后将文件描述符设置非阻塞方式
/**
  * @param epollfd: epoll句柄
  * @param fd: 文件描述符
  * @param enable_et : enable_et = true, 
     采用epoll的ET工作方式;否则采用LT工作方式
**/
void addfd(int epollfd, int fd, bool enable_et )
{
    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN;
    if( enable_et )
        ev.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
    setnonblocking(fd);
    printf("fd added to epoll!\n\n");
}

2.5 代码结构

​ 根据上述细化需求,我们先创建必要的程序文件。

首先为要实现的程序命名为chatroom:

# 创建代码目录
cd /home/shiyanlou
mkdir chatroom && cd chatroom

# 创建所需的文件
touch Common.h Client.h Client.cpp ClientMain.cpp
touch Server.h Server.cpp ServerMain.cpp
touch Makefile

每个文件的作用:

  1. Common.h:公共头文件,包含所需的所有宏定义及socket网络编程头文件
  2. Client.hClient.cpp:客户端类实现。
  3. Server.hServer.cpp:服务端类实现。
  4. ClientMain.cppServerMain.cpp:客户端及服务端的主函数。

下面我们将开始实现需要的类。

2.6 Common.h

​ 在这个项目中,我们只需要定义一个单独的函数被类成员函数调用即可,这个功能函数的作用就是2.4最后提到的将文件描述符fd添加到epollfd标示的内核事件表中。因此我们将函数定义放在头文件 Common.h 中。

除了这个功能函数之外,我们还需要把客户端和服务器端共用的宏定义放在 Common.h中,例如:

  1. 服务器地址
  2. 服务器端口号
  3. 消息缓存大小
  4. 服务器端默认的欢迎及退出消息

根据上面描述请独立实现 Common.h 文件,可以参考课程提供的示例代码。

#ifndef  CHATROOM_COMMON_H
#define CHATROOM_COMMON_H

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 默认服务器端IP地址
#define SERVER_IP "127.0.0.1"

// 服务器端口号
#define SERVER_PORT 8888

// int epoll_create(int size)中的size
// 为epoll支持的最大句柄数
#define EPOLL_SIZE 5000

// 缓冲区大小65535
#define BUF_SIZE 0xFFFF
    
// 新用户登录后的欢迎信息
#define SERVER_WELCOME "Welcome you join to the chat room! Your chat ID is: Client #%d"

// 其他用户收到消息的前缀
#define SERVER_MESSAGE "ClientID %d say >> %s"

// 退出系统
#define EXIT "EXIT"

// 提醒你是聊天室中唯一的客户
#define CAUTION "There is only one int the char room!"


// 注册新的fd到epollfd中
// 参数enable_et表示是否启用ET模式,如果为True则启用,否则使用LT模式
static void addfd( int epollfd, int fd, bool enable_et )
{
    struct epoll_event ev;
    ev.data.fd = fd;
    ev.events = EPOLLIN;
    if( enable_et )
        ev.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
    // 设置socket为nonblocking模式
    // 执行完就转向下一条指令,不管函数有没有返回。
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFD, 0)| O_NONBLOCK);
    printf("fd added to epoll!\n\n");
}

#endif // CHATROOM_COMMON_H

2.7 服务器端实现

服务端类根据2.2的分析,我们需要下面的接口:

  1. 初始化 Init()
  2. 关闭服务 Close()
  3. 启动服务 Start()
  4. 广播消息给所有客户端 SendBoradcastMessage()

​ 服务器主循环中都每次都会检查并处理 EPOLL 中的就绪事件。而就绪事件列表主要是两种类型:**新连接或新消息。**服务器会依次从这个列表中提取事件进行处理,如果是新连接则 accept() 接受连接并 addfd()。如果是新消息则广播给当前连接到服务器的所有客户端,从而实现聊天室的效果。

​ 广播消息的代码中首先使用 recv() 读取收到的消息,然后查看消息长度,如果消息长度为0,则认为是客户端中止连接的消息,从而 close()并从客户端列表中移除该客户端。如果消息长度不为0则为有效的消息,需要首先 sprintf() 对消息进行格式化,包含一些必要的信息,然后从客户端列表中循环取出每个客户端的 fd,使用 send() 发出消息给每个客户端。

其中每个函数的伪代码流程如下,请根据上述的2.3节和2.4节中的内容独立实现:

2.7.1 Init()

// 初始化服务端并启动监听
void Server::Init() {
	
    // Step 1:创建监听socket
    // 使用socket()

    // Step 2:绑定地址
    // 使用bind()

    // Step 3:监听连接
    // 使用listen()

    // Step 4:创建事件表
    // epoll_create()

    // Step 5:添加监听fd到epoll fd
    // addfd()
}

2.7.2 Close()

比较简单,只需要关闭所有打开的文件描述符就可以。

2.7.3 SendBroadcastMessage()

// 发送广播消息给所有客户端
int Server::SendBroadcastMessage(int clientfd) {
    // Step 1:接收新消息
    // recv()

    // Step 2:判断是否是客户端中止连接

    // Step 3:判断是否聊天室还有其他客户端

    // Step 4:格式化发送的消息内容
    // sprintf()

    // Step 5:遍历客户端列表依次发送消息
    // send()
}

2.7.4 Start()

// 启动服务端
void Server::Start() {
    // Step 1:初始化服务端
    // Init()

    // Step 2:进入主循环

    // Step 3:获取就绪的事件
    // epoll_wait()

    // Step 4:循环处理所有就绪的事件
    // 4.1 如果是新连接则接受连接并将连接添加到epoll fd
    // accept() addfd()

    // 4.2 如果是新消息则广播给其他客户端
    // SendBroadcastMessage
}

上述功能都可以在先前的基础知识介绍中找到示例代码,如果确实有难度可以参考本项目提供的完整代码或在实验楼问答中提问。

Server.hServer.cpp 文件实现后,我们需要完成ServerMain.cpp文件中的主函数。主函数只需要创建一个Server对象,并调用Start()接口。

Server.h

#ifndef CHATROOM_SERVER_H
#define CHATROOM_SERVER_H

#include 

#include "Common.h"

using namespace std;

// 服务端类,用来处理客户端请求
class Server {

public:
    // 无参数构造函数
    Server();

    // 初始化服务器端设置
    void Init();

    // 关闭服务
    void Close();

    // 启动服务端
    void Start();

private:
    // 广播消息给所有客户端
    int SendBroadcastMessage(int clientfd);

    // 服务器端serverAddr信息
    struct sockaddr_in serverAddr;
    
    //创建监听的socket
    int listener;

    // epoll_create创建后的返回值
    int epfd;
    
    // 客户端列表
    list<int> clients_list;
};



#endif //CHATROOM_SERVER_H

Server.cpp

#include 

#include "Server.h"

using namespace std;

// 服务端类成员函数

// 服务端类构造函数
Server::Server(){
    
    // 初始化服务器地址和端口
    serverAddr.sin_family = PF_INET;
    serverAddr.sin_port = htons(SERVER_PORT);
    serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);

    // 初始化socket
    listener = 0;
    
    // epool fd
    epfd = 0;
}

// 初始化服务端并启动监听
void Server::Init() {
    cout << "Init Server..." << endl;
    
     //创建监听socket
    listener = socket(PF_INET, SOCK_STREAM, 0);
    if(listener < 0) { perror("listener"); exit(-1);}
    
    //绑定地址
    if( bind(listener, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
        perror("bind error");
        exit(-1);
    }

    //监听
    int ret = listen(listener, 5);
    if(ret < 0) {
        perror("listen error"); 
        exit(-1);
    }

    cout << "Start to listen: " << SERVER_IP << endl;

    //在内核中创建事件表
    epfd = epoll_create(EPOLL_SIZE);
    
    if(epfd < 0) {
        perror("epfd error");
        exit(-1);
    }

    //往事件表里添加监听事件
    addfd(epfd, listener, true);

}

// 关闭服务,清理并关闭文件描述符
void Server::Close() {

    //关闭socket
    close(listener);
    
    //关闭epoll监听
    close(epfd);
}

// 发送广播消息给所有客户端
int Server::SendBroadcastMessage(int clientfd)
{
    // buf[BUF_SIZE] 接收新消息
    // message[BUF_SIZE] 保存格式化的消息
    char buf[BUF_SIZE], message[BUF_SIZE];
    bzero(buf, BUF_SIZE);
    bzero(message, BUF_SIZE);

    // 接收新消息
    cout << "read from client(clientID = " << clientfd << ")" << endl;
    int len = recv(clientfd, buf, BUF_SIZE, 0);

    // 如果客户端关闭了连接
    if(len == 0) 
    {
        close(clientfd);
        
        // 在客户端列表中删除该客户端
        clients_list.remove(clientfd);
        cout << "ClientID = " << clientfd 
             << " closed.\n now there are " 
             << clients_list.size()
             << " client in the char room"
             << endl;

    }
    // 发送广播消息给所有客户端
    else 
    {
        // 判断是否聊天室还有其他客户端
        if(clients_list.size() == 1) { 
            // 发送提示消息
            send(clientfd, CAUTION, strlen(CAUTION), 0);
            return len;
        }
        // 格式化发送的消息内容
        sprintf(message, SERVER_MESSAGE, clientfd, buf);

        // 遍历客户端列表依次发送消息,需要判断不要给来源客户端发
        list<int>::iterator it;
        for(it = clients_list.begin(); it != clients_list.end(); ++it) {
           if(*it != clientfd){
                if( send(*it, message, BUF_SIZE, 0) < 0 ) {
                    return -1;
                }
           }
        }
    }
    return len;
}

// 启动服务端
void Server::Start() {

    // epoll 事件队列
    static struct epoll_event events[EPOLL_SIZE]; 

    // 初始化服务端
    Init();

    //主循环
    while(1)
    {
        //epoll_events_count表示就绪事件的数目
        int epoll_events_count = epoll_wait(epfd, events, EPOLL_SIZE, -1);

        if(epoll_events_count < 0) {
            perror("epoll failure");
            break;
        }

        cout << "epoll_events_count =\n" << epoll_events_count << endl;

        //处理这epoll_events_count个就绪事件
        for(int i = 0; i < epoll_events_count; ++i)
        {
            int sockfd = events[i].data.fd;
            //新用户连接
            if(sockfd == listener)
            {
                struct sockaddr_in client_address;
                socklen_t client_addrLength = sizeof(struct sockaddr_in);
                int clientfd = accept( listener, ( struct sockaddr* )&client_address, &client_addrLength );

                cout << "client connection from: "
                     << inet_ntoa(client_address.sin_addr) << ":"
                     << ntohs(client_address.sin_port) << ", clientfd = "
                     << clientfd << endl;

                addfd(epfd, clientfd, true);

                // 服务端用list保存用户连接
                clients_list.push_back(clientfd);
                cout << "Add new clientfd = " << clientfd << " to epoll" << endl;
                cout << "Now there are " << clients_list.size() << " clients int the chat room" << endl;

                // 服务端发送欢迎信息  
                cout << "welcome message" << endl;                
                char message[BUF_SIZE];
                bzero(message, BUF_SIZE);
                sprintf(message, SERVER_WELCOME, clientfd);
                int ret = send(clientfd, message, BUF_SIZE, 0);
                if(ret < 0) {
                    perror("send error");
                    Close();
                    exit(-1);
                }
            }
            //处理用户发来的消息,并广播,使其他用户收到信息
            else {   
                int ret = SendBroadcastMessage(sockfd);
                if(ret < 0) {
                    perror("error");
                    Close();
                    exit(-1);
                }
            }
        }
    }

    // 关闭服务
    Close();
}

ClientMain.cpp

#include "Client.h"

// 客户端主函数
// 创建客户端对象后启动客户端
int main(int argc, char *argv[]) {
    Client client;
    client.Start();
    return 0;
}

2.8 客户端实现

客户端类根据2.2的分析,我们需要下面的接口:

  1. 连接服务端Connect()
  2. 退出连接Close()
  3. 启动客户端Start()

其中每个函数的伪代码流程如下,请根据上述的2.3节和2.4节中的内容独立实现:

2.8.1 Connect()

// 连接服务器
void Server::Connect() {

    // Step 1:创建socket
    // 使用socket()

    // Step 2:连接服务端
    // connect()

    // Step 3:创建管道,其中fd[0]用于父进程读,fd[1]用于子进程写
    // 使用pipe()

    // Step 4:创建epoll
    // epoll_create()

    // Step 5:将sock和管道读端描述符都添加到内核事件表中
    // addfd()
}

2.8.2 Close()

需要关闭所有打开的文件描述符就可以。

需要注意判断是在父进程还是子进程,客户端中的两个进程打开的管道文件描述符是不同的。

2.8.2 Start()

// 启动客户端
void Client::Start() {
    // Step 1:连接服务器
    // Connect()

    // Step 2:创建子进程
    // fork()

    // Step 3:进入子进程执行流程
    // 子进程负责收集用户输入的消息并写入管道
    // fgets() write(pipe_fd[1])

    // Step 4:进入父进程执行流程
    // 父进程负责读管道数据及epoll事件
    // 4.1 获取就绪事件
    // epoll_wait()
    // 4.2 处理就绪事件
    // 接收服务器端消息并显示 recv()
    // 读取管道消息并发给服务端 read() send()
}

上述功能都可以在先前的基础知识介绍中找到示例代码,如果确实有难度可以参考本项目提供的完整代码或在实验楼问答中提问。

Client.hClient.cpp 文件实现后,我们需要完成 ClientMain.cpp 文件中的主函数。主函数只需要创建一个 Client 对象,并调用Start()接口。

Client.h

#ifndef CHATROOM_CLIENT_H
#define CHATROOM_CLIENT_H

#include 
#include "Common.h"

using namespace std;

// 客户端类,用来连接服务器发送和接收消息
class Client {

public:
    // 无参数构造函数
    Client();

    // 连接服务器
    void Connect();

    // 断开连接
    void Close();

    // 启动客户端
    void Start();

private:

    // 当前连接服务器端创建的socket
    int sock;

    // 当前进程ID
    int pid;
    
    // epoll_create创建后的返回值
    int epfd;

    // 创建管道,其中fd[0]用于父进程读,fd[1]用于子进程写
    int pipe_fd[2];

    // 表示客户端是否正常工作
    bool isClientwork;

    // 聊天信息缓冲区
    char message[BUF_SIZE];

    //用户连接的服务器 IP + port
    struct sockaddr_in serverAddr;
};



#endif //CHATROOM_CLIENT_H

Client.cpp

#include 

#include "Client.h"

using namespace std;

// 客户端类成员函数

// 客户端类构造函数
Client::Client(){
    
    // 初始化要连接的服务器地址和端口
    serverAddr.sin_family = PF_INET;
    serverAddr.sin_port = htons(SERVER_PORT);
    serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);
    
    // 初始化socket
    sock = 0;
    
    // 初始化进程号
    pid = 0;
    
    // 客户端状态
    isClientwork = true;
    
    // epool fd
    epfd = 0;
}

// 连接服务器
void Client::Connect() {
    cout << "Connect Server: " << SERVER_IP << " : " << SERVER_PORT << endl;
    
    // 创建socket
    sock = socket(PF_INET, SOCK_STREAM, 0);
    if(sock < 0) {
        perror("sock error");
        exit(-1); 
    }

    // 连接服务端
    if(connect(sock, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
        perror("connect error");
        exit(-1);
    }

    // 创建管道,其中fd[0]用于父进程读,fd[1]用于子进程写
    if(pipe(pipe_fd) < 0) {
        perror("pipe error");
        exit(-1);
    }

    // 创建epoll
    epfd = epoll_create(EPOLL_SIZE);
    
    if(epfd < 0) {
        perror("epfd error");
        exit(-1); 
    }

    //将sock和管道读端描述符都添加到内核事件表中
    addfd(epfd, sock, true);
    addfd(epfd, pipe_fd[0], true);

}

// 断开连接,清理并关闭文件描述符
void Client::Close() {

    if(pid){
       //关闭父进程的管道和sock
        close(pipe_fd[0]);
        close(sock);
    }else{
        //关闭子进程的管道
        close(pipe_fd[1]);
    }
}

// 启动客户端
void Client::Start() {

    // epoll 事件队列
    static struct epoll_event events[2];
    
    // 连接服务器
    Connect();
    
    // 创建子进程
    pid = fork();
    
    // 如果创建子进程失败则退出
    if(pid < 0) {
        perror("fork error");
        close(sock);
        exit(-1);
    } else if(pid == 0) {
        // 进入子进程执行流程
        //子进程负责写入管道,因此先关闭读端
        close(pipe_fd[0]); 

        // 输入exit可以退出聊天室
        cout << "Please input 'exit' to exit the chat room" << endl;

        // 如果客户端运行正常则不断读取输入发送给服务端
        while(isClientwork){
            bzero(&message, BUF_SIZE);
            fgets(message, BUF_SIZE, stdin);

            // 客户输出exit,退出
            if(strncasecmp(message, EXIT, strlen(EXIT)) == 0){
                isClientwork = 0;
            }
            // 子进程将信息写入管道
            else {
                if( write(pipe_fd[1], message, strlen(message) - 1 ) < 0 ) { 
                    perror("fork error");
                    exit(-1);
                }
            }
        }
    } else { 
        //pid > 0 父进程
        //父进程负责读管道数据,因此先关闭写端
        close(pipe_fd[1]); 

        // 主循环(epoll_wait)
        while(isClientwork) {
            int epoll_events_count = epoll_wait( epfd, events, 2, -1 );

            //处理就绪事件
            for(int i = 0; i < epoll_events_count ; ++i)
            {
                bzero(&message, BUF_SIZE);

                //服务端发来消息
                if(events[i].data.fd == sock)
                {
                    //接受服务端消息
                    int ret = recv(sock, message, BUF_SIZE, 0);

                    // ret= 0 服务端关闭
                    if(ret == 0) {
                        cout << "Server closed connection: " << sock << endl;
                        close(sock);
                        isClientwork = 0;
                    } else {
                        cout << message << endl;
                    }
                }
                //子进程写入事件发生,父进程处理并发送服务端
                else { 
                    //父进程从管道中读取数据
                    int ret = read(events[i].data.fd, message, BUF_SIZE);

                    // ret = 0
                    if(ret == 0)
                        isClientwork = 0;
                    else {
                        // 将信息发送给服务端
                        send(sock, message, BUF_SIZE, 0);
                    }
                }
            }//for
        }//while
    }
    
    // 退出进程
    Close();
}

ServerMain.cpp

#include "Server.h"

// 服务端主函数
// 创建服务端对象后启动服务端
int main(int argc, char *argv[]) {
    Server server;
    server.Start();
    return 0;
}

2.9 编译及运行

编辑 Makefile 文件:

cd /home/shiyanlou/chatroom 

vim Makefile 

Makefile 文件里的内容为我们上述每个编译和链接步骤的整合:

CC = g++
CFLAGS = -std=c++11

all: ClientMain.cpp ServerMain.cpp Server.o Client.o
    $(CC) $(CFLAGS) ServerMain.cpp  Server.o -o chatroom_server
    $(CC) $(CFLAGS) ClientMain.cpp Client.o -o chatroom_client

Server.o: Server.cpp Server.h Common.h
    $(CC) $(CFLAGS) -c Server.cpp

Client.o: Client.cpp Client.h Common.h
    $(CC) $(CFLAGS) -c Client.cpp

clean:
    rm -f *.o chatroom_server chatroom_client

再次注意(CC)(CC)(CFLAGS) ...前为一个tab,不是空格。

保存 Makefile 后,我们只需要在目录下执行make就可以生成可执行文件chatroom_serverchatroom_client

现在进入运行测试阶段,首先启动服务端:

./chatroom_server

然后再打开新的XFCE终端,启动客户端:

./chatroom_client

可以看到服务端和客户端分别有一些日志输出,客户端也会收到欢迎信息。

为了加入更多的客户,可以打开新的XFCE终端,启动新的客户,每个客户的clientfd是不同的,发出去的消息在其他客户界面都可以看到来源。

可以在不同的客户端界面发消息进行测试,截图如下:

测试中如果出现下面的问题,说明是服务器异常关闭,端口还没有释放,可以修改Common.h中使用的服务器端口换一个编译继续使用。

如果中间有任何问题,需要根据输出的错误信息查验下代码是否有BUG。

知识补充

struct epoll_event
  // 结构体epoll_event被用于注册所感兴趣的事件和回传所发生待处理的事件,定义如下:
    typedef union epoll_data {
        void *ptr;
         int fd;
         __uint32_t u32;
         __uint64_t u64;
     } epoll_data_t;//保存触发事件的某个文件描述符相关的数据
     struct epoll_event {
         __uint32_t events;      /* epoll event */
         epoll_data_t data;      /* User data variable */
     };

​ 结构体epoll_event 被用于注册所感兴趣的事件和回传所发生待处理的事件,其中epoll_data 联合体用来保存触发事件的某个文件描述符相关的数据,例如一个client连接到服务器,服务器通过调用accept函数可以得到于这个client对应的socket文件描述符,可以把这文件描述符赋给epoll_data的fd字段以便后面的读写操作在这个文件描述符上进行。
EPOLLIN:表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数可读;

EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: ET的epoll工作模式;

所涉及到的函数有:

1、epoll_create函数
函数声明:int epoll_create(int size)
功能:该函数生成一个epoll专用的文件描述符,其中的参数是指定生成描述符的最大范围;

2、epoll_ctl函数
函数声明:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
功能:用于控制某个文件描述符上的事件,可以注册事件,修改事件,删除事件。
@epfd:由epoll_create生成的epoll专用的文件描述符;
@op:要进行的操作,EPOLL_CTL_ADD注册、EPOLL_CTL_MOD修改、EPOLL_CTL_DEL删除;
@fd:关联的文件描述符;
@event:指向epoll_event的指针;
成功:0;失败:-1

3、epoll_wait函数
函数声明:int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
功能:该函数用于轮询I/O事件的发生;
@epfd:由epoll_create生成的epoll专用的文件描述符;
@epoll_event:用于回传代处理事件的数组;
@maxevents:每次能处理的事件数;
@timeout:等待I/O事件发生的超时值;
成功:返回发生的事件数;失败:-1

应用举例:

int main()
{
  int i, maxi, listenfd, new_fd, sockfd, epfd, nfds;
  ssize_t n;
  char line[MAXLINE];
  socklen_t clilen;
  struct epoll_event ev,events[20];//ev用于注册事件,数组用于回传要处理的事件
  struct sockaddr_in clientaddr, serveraddr;
  listenfd = socket(AF_INET, SOCK_STREAM, 0);//生成socket文件描述符
  setnonblocking(listenfd);//把socket设置为非阻塞方式
  epfd = epoll_create(256);//生成用于处理accept的epoll专用的文件描述符
  ev.data.fd = listenfd;      //设置与要处理的事件相关的文件描述符
  ev.events = EPOLLIN|EPOLLET;//设置要处理的事件类型
  epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);//注册epoll事件
    //设置服务器端地址信息
  bzero(&serveraddr, sizeof(serveraddr));
  serveraddr.sin_family = AF_INET;
  char *local_addr = LOCAL_ADDR;
  inet_aton(local_addr, &(serveraddr.sin_addr));
  serveraddr.sin_port = htons(SERV_PORT);
  bind(listenfd, (sockaddr *)&serveraddr, sizeof(serveraddr));//绑定socket连接
  listen(listenfd, LISTENQ);//监听
  maxi = 0;
  for ( ; ; )
      {
         /* epoll_wait:等待epoll事件的发生,并将发生的sokct fd和事件类型放入到events数组中;
          * nfds:为发生的事件的个数。
          * 注:
         */
      nfds=epoll_wait(epfd,events,20,500);
      //处理所发生的所有事件
      for(i=0;i<nfds;++i)
      {
          if(events[i].data.fd==listenfd)//事件发生在listenfd上
          {
               /* 获取发生事件端口信息,存于clientaddr中;
               *new_fd:返回的新的socket描述符,用它来对该事件进行recv/send操作*/
              new_fd = accept(listenfd,(struct sockaddr *)&clientaddr, &clilen);
              if(new_fd<0)
                   {
                  perror("new_fd<0");
                  exit(1);
              }
              setnonblocking(new_fd);
              char *str = inet_ntoa(clientaddr.sin_addr);
              ev.data.fd = new_fd;//设置用于读操作的文件描述符
              ev.events = EPOLLIN|EPOLLET;//设置用于注测的读操作事件
              epoll_ctl(epfd, , ,&ev);//注册ev
          }
          else if(events[i].events&EPOLLIN)
          {
              if ( (sockfd = events[i].data.fd) < 0)
                       continue;
              if ( (n = read(sockfd, line, MAXLINE)) < 0)
                   {
                  if (errno == ECONNRESET)
                      {
                      close(sockfd);
                      events[i].data.fd = -1;
                  }
                      else
                      std::cout<<"readline error"<<std::endl;
              }
                  else if (n == 0)
                  {
                  close(sockfd);
                  events[i].data.fd = -1;
             }
             ev.data.fd=sockfd;//设置用于写操作的文件描述符
             ev.events=EPOLLOUT|EPOLLET;//设置用于注测的写操作事件
             epoll_ctl(epfd,,sockfd,&ev);//修改sockfd上要处理的事件为EPOLLOUT
        }
       else if(events[i].events&EPOLLOUT)
       {
           sockfd = events[i].data.fd;
            write(sockfd, line, n);
            ev.data.fd=sockfd;//设置用于读操作的文件描述符
            ev.events=EPOLLIN|EPOLLET;//设置用于注测的读操作事件
            epoll_ctl(epfd,,sockfd,&ev);//修改sockfd上要处理的事件为EPOLIN
       }
   }
 }
}

你可能感兴趣的:(C++)