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 eventsepoll_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 <iostream>
#include <list>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>// 默认服务器端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 <string>#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;//创建监听的socketint listener;// epoll_create创建后的返回值int epfd;// 客户端列表list<int> clients_list;
};#endif //CHATROOM_SERVER_H

Server.cpp

#include <iostream>#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);// 初始化socketlistener = 0;// epool fdepfd = 0;
}// 初始化服务端并启动监听
void Server::Init() {cout << "Init Server..." << endl;//创建监听socketlistener = 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() {//关闭socketclose(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 <string>
#include "Common.h"using namespace std;// 客户端类,用来连接服务器发送和接收消息
class Client {public:// 无参数构造函数Client();// 连接服务器void Connect();// 断开连接void Close();// 启动客户端void Start();private:// 当前连接服务器端创建的socketint sock;// 当前进程IDint pid;// epoll_create创建后的返回值int epfd;// 创建管道,其中fd[0]用于父进程读,fd[1]用于子进程写int pipe_fd[2];// 表示客户端是否正常工作bool isClientwork;// 聊天信息缓冲区char message[BUF_SIZE];//用户连接的服务器 IP + portstruct sockaddr_in serverAddr;
};#endif //CHATROOM_CLIENT_H

Client.cpp

#include <iostream>#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);// 初始化socketsock = 0;// 初始化进程号pid = 0;// 客户端状态isClientwork = true;// epool fdepfd = 0;
}// 连接服务器
void Client::Connect() {cout << "Connect Server: " << SERVER_IP << " : " << SERVER_PORT << endl;// 创建socketsock = 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);}// 创建epollepfd = 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){//关闭父进程的管道和sockclose(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 = 0if(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++11all: 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_clientServer.o: Server.cpp Server.h Common.h$(CC) $(CFLAGS) -c Server.cppClient.o: Client.cpp Client.h Common.h$(CC) $(CFLAGS) -c Client.cppclean: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;}elsestd::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++实现即时通信软件相关推荐

  1. 和java通信_[源码和文档分享]基于JAVA的即时通信软件

    一.设计任务书 1.1 设计任务 本文设计的是一个简单的即时通信软件,利用 Java Socket 进行点到点通信,其工作机制模仿即时通信软件的基本功能,已实现的功能有:客户端登录 客户端退出 群组成 ...

  2. [源码和文档分享]基于JAVA的即时通信软件

    一.设计任务书 1.1 设计任务 本文设计的是一个简单的即时通信软件,利用 Java Socket 进行点到点通信,其工作机制模仿即时通信软件的基本功能,已实现的功能有: 客户端登录 客户端退出 群组 ...

  3. 即时通信软件开发的年轻开发者曾注意到

    微软"迷失"的十年,已成为美国企业发展史上最大的谜团之一.为了解其中缘由,曾两次获得乔治·波尔卡新闻奖(George Polk Award)的美国知名记者.最近刚刚担任<名利 ...

  4. 计算机网络课程设计即时通讯,计算机网络课程设计报告-基于LAN的即时通信软件.doc...

    一.概述 1.1 设计目的: 利用MFC编程实现客户端之间通过服务器进行通信. 1.2 设计内容: 网络通信软件的数据通信是通过网络套接字进行的.根据该原理,其编程步骤应分为创建套接字.在套接字上进行 ...

  5. 科聊——即时通信软件原型设计

    原型展示地址:科聊 原型设计工具:墨刀 运行环境:Android,Web浏览器(Chrome测试) 安卓下载: 说明:产品原型是整个产品面市之前的一个框架设计,本产品原型对框架结构做出了基本搭建,未注 ...

  6. C语言项目(四)——基于Linux系统下的带有GUI界面的即时通信软件

    二十一.TCP是如何保证可靠数据传输的? TCP提供一种面向连接的.可靠的字节流服务. 面向连接:意味着两个使用TCP的应用(通常是一个客户和一个服务器)在彼此交换数据之前必须先建立一个TCP连接.在 ...

  7. 局域网即时通信软件都有哪些?要如何选择?

    近些年来,由于企业内部敏感信息通过即时通信软件和互联网泄露的事件频发,许多企业对于内部信息安全保护越来越重视.除了使用局域网环境办公以外,还会采用局域网即时通信软件来作为内部沟通工具,降低信息泄露的风 ...

  8. 微信、QQ等即时通信软件为什么没有取代电子邮件?

    如今,社会信息化和网络化的发展导致数据量爆炸式增长,微信.QQ.陌陌.facebook等,种类繁多的应用软件为我们沟通交流搭建更加便捷的桥梁,那为什么我们依然需要使用电子邮件呢? 1.电子邮件具有较高 ...

  9. 安全即时通信软件简介

    这是小弟参加全国信息安全竞赛的作品简介,供大家参考. 安全即时通信软件作品简介 摘要:针对即时通信用户的安全需要,设计并实现了基于微软MSN的安全即时通信软件.该软件能够解决即时通信用户之间的身份认证 ...

  10. 【网络编程入门】使用socket在Linux下实现即时通信软件

    使用socket在Linux下实现即时通信软件 在前一篇文章中讲到了如何使用winsock:[网络编程入门]在C++中使用Windows TCP Sockets,也算是勉强入门了吧,接下来自己写一下在 ...

最新文章

  1. antd+dva笔记
  2. vs修改 exe名字
  3. 分类器是如何做检测的?——CascadeClassifier中的detectMultiScale函数解读
  4. Hadoop MapReduce执行过程(一)
  5. 递归——黑白棋子的移动(洛谷 P1259)
  6. java gpio_单片机基础——使用GPIO输出点亮一个LED灯
  7. vi: 未找到命令_vi技巧和窍门:十个很棒的命令一定会给您的朋友留下深刻的印象...
  8. C++PrimerPlus 第六章 分支语句和逻辑运算符 - 6.1 if语句
  9. 轻松搞定iOS自动化环境搭建
  10. android 播放assets下视频,安卓播放assets文件里视频文件相关问题分析
  11. 写文档时经常用到的图标(对勾、叉号)
  12. ChaosBlade:混沌工程简介(一)
  13. robots.txt
  14. 数据结构(java版)SortedSeqList(排序顺序表)
  15. java quartz下载_下载、设置和运行Quartz(GUI)图形界面程序----Quartz Web
  16. lisp写标高线_基于Autolisp语言的等高线批量赋标高程序
  17. 【三维重建学习之路01】点云ply文件的读写、修改
  18. Java实现 蓝桥杯VIP 算法提高 3-2字符串输入输出函数
  19. 阿里云视频点播PHP sdk上传文件
  20. 通过2万+的二手房信息,我发现了二手房交易的内幕

热门文章

  1. (c语言)单、双精度
  2. macOS Monterey 12.3 beta3 With OpenCore 0.7.9 and Clover 5144 and winPE
  3. php小程序支付获取prepay_id,小程序支付流程JSAPI
  4. dede调用dz论坛数据-html方式调用
  5. 年金用计算机怎么算,现值终值计算公式(普通年金终值计算器怎么用)
  6. Kotlin 超车指南
  7. java实现双色球机选功能
  8. 线性代数介绍-1-向量
  9. Samsung Retail SSD 三星零售固态硬盘 尾缀版本说明
  10. 笔记本获取服务器上的文件,win7笔记本读取服务器里的文件肿么弄???