一:項目內容
本項目使用C++實現一個具備服務器端和客戶端即時通信且具有私聊功能的聊天室。
目的是學習C++網絡開發的基本概念,同時也可以熟悉下linux下的C++程序編譯和簡單MakeFile編寫
分享一個即時通訊的實戰項目給大家:即時通訊實戰項目
需要Linux服務器開發高階學習資料的朋友可以后臺私信【架構】獲取
包括Linux,Nginx,ZeroMQ,MySQL,redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK等等視頻學習資料。
二:需求分析
這個聊天室主要有兩個程序:
1.服務端:能夠接受新的客戶連接,并將每個客戶端發來的信息,廣播給對應的目標客戶端。
2.客戶端:能夠連接服務器,并向服務器發送消息,同時可以接收服務器發來的消息。
即最簡單的C/S模型。
三:抽象與細化
服務端類需要支持:
1.支持多個客戶端接入,實現聊天室基本功能。
2.啟動服務,建立監聽端口等待客戶端連接。
3.使用epoll機制實現并發,增加效率。
4.客戶端連接時,發送歡迎消息,并存儲連接記錄。
5.客戶端發送消息時,根據消息類型,廣播給所有用戶(群聊)或者指定用戶(私聊)。
6.客戶端請求退出時,對相應連接信息進行清理。
客戶端類需要支持:
1.連接服務器。
2.支持用戶輸入消息,發送給服務端。
3.接受并顯示服務端發來的消息。
4.退出連接。
涉及兩個事情,一個寫,一個讀。所以客戶端需要兩個進程分別支持以下功能。
子進程:
1.等待用戶輸入信息。
2.將聊天信息寫入管道(pipe),并發送給父進程。
父進程:
1.使用epoll機制接受服務端發來的消息,并顯示給用戶,使用戶看到其他用戶的信息。
2.將子進程發送的聊天信息從管道(pipe)中讀取出來,并發送給客戶端。
四:C/S模型

TCP服務端通信常規步驟:
1.socket()創建TCP套接字
2.bind()將創建的套接字綁定到一個本地地址和端口上
3.listen(),將套接字設為監聽模式,準備接受客戶請求
4.accept()等用戶請求到來時接受,返回一個對應此連接新套接字
5.用accept()返回的套接字和客戶端進行通信,recv()/send() 接受/發送信息。
6.返回,等待另一個客戶請求。
7.關閉套接字
TCP客戶端通信常規步驟:
1.socket()創建TCP套接字。
2.connect()建立到達服務器的連接。
3.與客戶端進行通信,recv()/send()接受/發送信息,write()/read() 子進程寫入管道,父進程從管道中讀取信息然后send給客戶端
5. close() 關閉客戶連接。
五:相關技術介紹
1.socket 阻塞與非阻塞。
阻塞與非阻塞關注的是程序在等待調用結果時(消息,返回值)的狀態。
阻塞調用是指在調用結果返回前,當前線程會被掛起,調用線程只有在得到調用結果之后才會返回。
非阻塞調用是指在不能立刻得到結果之前,該調用不會阻塞當前線程。
eg. 你打電話問書店老板有沒有《網絡編程》這本書,老板去書架上找,如果是阻塞式調用,你就會把自己一直掛起,守在電話邊上,直到得到這本書有或者沒有的答案。如果是非阻塞式調用,你可以干別的事情去,隔一段時間來看一下老板有沒有告訴你結果。
同步異步是對書店老板而言(同步老板不會提醒你找到結果了,異步老板會打電話告訴你),阻塞和非阻塞是對你而言。
socket()函數創建套接字時,默認的套接字都是阻塞的,非阻塞設置方式代碼:
//將文件描述符設置為非阻塞方式(利用fcntl函數)
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)| O_NONBLOCK);
2. 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
Epoll 用法(三步曲):
第一步:int epoll_create(int size)系統調用,創建一個epoll句柄,參數size用來告訴內核監聽的數目,size為epoll支持的最大句柄數。
第二步:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) 事件注冊函數
參數 epfd為epoll的句柄。參數op 表示動作 三個宏來表示:EPOLL_CTL_ADD注冊新fd到epfd 、EPOLL_CTL_MOD 修改已經注冊的fd的監聽事件、EPOLL_CTL_DEL從epfd句柄中刪除fd。參數fd為需要監聽的標識符。參數結構體epoll_event告訴內核需要監聽的事件。
第三步:int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) 等待事件的產生,通過調用收集在epoll監控中已經發生的事件。參數struct epoll_event 是事件隊列 把就緒的事件放進去。
eg. 服務端使用epoll的時候步驟如下:
1.調用epoll_create()在linux內核中創建一個事件表。
2.然后將文件描述符(監聽套接字listener)添加到事件表中
3.在主循環中,調用epoll_wait()等待返回就緒的文件描述符集合。
4.分別處理就緒的事件集合,本項目中一共有兩類事件:新用戶連接事件和用戶發來消息事件。
六:代碼結構

每個文件的作用:
1.Common.h:公共頭文件,包括所有需要的宏以及socket網絡編程頭文件,以及消息結構體(用來表示消息類別等)
2.Client.h Client.cpp :客戶端類的實現
3.Server.h Server.cpp : 服務端類的實現
4.ClientMain.cpp ServerMain.cpp 客戶端及服務端的主函數。
七:代碼實現
Common.h
定義一些共用的宏定義,包括一些共用的網絡編程相關頭文件。
1)定義一個函數將文件描述符fd添加到epfd表示的內核事件表中供客戶端和服務端兩個類使用。
2)定義一個信息數據結構,用來表示傳送的信息,結構體包括發送方fd, 接收方fd,用來表示消息類別的type,還有文字信息。
函數recv() send() write() read() 參數傳遞是字符串,所以在傳送前/接受后要把結構體轉換為字符串/字符串轉換為結構體。
#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 SERVER_PRIVATE_MESSAGE "Client %d say to you privately >> %s"
#define SERVER_PRIVATE_ERROR_MESSAGE "Client %d is not in the chat room yet~"
// 退出系統
#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為非阻塞模式
// 套接字立刻返回,不管I/O是否完成,該函數所在的線程會繼續運行
//eg. 在recv(fd...)時,該函數立刻返回,在返回時,內核數據還沒準備好會返回WSAEWOULDBLOCK錯誤代碼
fcntl(fd, F_SETFL, fcntl(fd, F_GETFD, 0)| O_NONBLOCK);
printf("fd added to epoll!nn");
}
//定義信息結構,在服務端和客戶端之間傳送
struct Msg
{
int type;
int fromID;
int toID;
char content[BUF_SIZE];
};
#endif // CHATROOM_COMMON_H
服務端類 Server.h Server.cpp
服務端需要的接口:
1)init()初始化
2)Start()啟動服務
3)Close()關閉服務
4)廣播消息給所有客戶端函數 SendBroadcastMessage()
服務端的主循環中每次都會檢查并處理EPOLL中的就緒事件,而就緒事件列表主要是兩種類型:新連接或新消息。服務器會依次從就緒事件列表里提取事件進行處理,如果是新連接則accept()然后addfd(),如果是新消息則SendBroadcastMessage()實現聊天功能。
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;
//創建監聽的socket
int listener;
// epoll_create創建后的返回值
int epfd;
// 客戶端列表
list<int> clients_list;
};
//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);
// 初始化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是一個句柄
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 recv_buf[BUF_SIZE];
char send_buf[BUF_SIZE];
Msg msg;
bzero(recv_buf, BUF_SIZE);
// 接收新消息
cout << "read from client(clientID = " << clientfd << ")" << endl;
int len = recv(clientfd, recv_buf, BUF_SIZE, 0);
//清空結構體,把接受到的字符串轉換為結構體
memset(&msg,0,sizeof(msg));
memcpy(&msg,recv_buf,sizeof(msg));
//判斷接收到的信息是私聊還是群聊
msg.fromID=clientfd;
if(msg.content[0]=='\'&&isdigit(msg.content[1])){
msg.type=1;
msg.toID=msg.content[1]-'0';
memcpy(msg.content,msg.content+2,sizeof(msg.content));
}
else
msg.type=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){
// 發送提示消息
memcpy(&msg.content,CAUTION,sizeof(msg.content));
bzero(send_buf, BUF_SIZE);
memcpy(send_buf,&msg,sizeof(msg));
send(clientfd, send_buf, sizeof(send_buf), 0);
return len;
}
//存放格式化后的信息
char format_message[BUF_SIZE];
//群聊
if(msg.type==0){
// 格式化發送的消息內容 #define SERVER_MESSAGE "ClientID %d say >> %s"
sprintf(format_message, SERVER_MESSAGE, clientfd, msg.content);
memcpy(msg.content,format_message,BUF_SIZE);
// 遍歷客戶端列表依次發送消息,需要判斷不要給來源客戶端發
list<int>::iterator it;
for(it = clients_list.begin(); it != clients_list.end(); ++it) {
if(*it != clientfd){
//把發送的結構體轉換為字符串
bzero(send_buf, BUF_SIZE);
memcpy(send_buf,&msg,sizeof(msg));
if( send(*it,send_buf, sizeof(send_buf), 0) < 0 ) {
return -1;
}
}
}
}
//私聊
if(msg.type==1){
bool private_offline=true;
sprintf(format_message, SERVER_PRIVATE_MESSAGE, clientfd, msg.content);
memcpy(msg.content,format_message,BUF_SIZE);
// 遍歷客戶端列表依次發送消息,需要判斷不要給來源客戶端發
list<int>::iterator it;
for(it = clients_list.begin(); it != clients_list.end(); ++it) {
if(*it == msg.toID){
private_offline=false;
//把發送的結構體轉換為字符串
bzero(send_buf, BUF_SIZE);
memcpy(send_buf,&msg,sizeof(msg));
if( send(*it,send_buf, sizeof(send_buf), 0) < 0 ) {
return -1;
}
}
}
//如果私聊對象不在線
if(private_offline){
sprintf(format_message,SERVER_PRIVATE_ERROR_MESSAGE,msg.toID);
memcpy(msg.content,format_message,BUF_SIZE);
bzero(send_buf,BUF_SIZE);
memcpy(send_buf,&msg,sizeof(msg));
if(send(msg.fromID,send_buf,sizeof(send_buf),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();
}
客戶端類實現
需要的接口:
1)連接服務端connect()
2)退出連接close()
3)啟動客戶端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:
// 當前連接服務器端創建的socket
int sock;
// 當前進程ID
int pid;
// epoll_create創建后的返回值
int epfd;
// 創建管道,其中fd[0]用于父進程讀,fd[1]用于子進程寫
int pipe_fd[2];
// 表示客戶端是否正常工作
bool isClientwork;
// 聊天信息
Msg msg;
//結構體要轉換為字符串
char send_buf[BUF_SIZE];
char recv_buf[BUF_SIZE];
//用戶連接的服務器 IP + port
struct sockaddr_in serverAddr;
};
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);
// 初始化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;
cout<<"\ + ClientID to private chat "<<endl;
// 如果客戶端運行正常則不斷讀取輸入發送給服務端
while(isClientwork){
//清空結構體
memset(msg.content,0,sizeof(msg.content));
fgets(msg.content, BUF_SIZE, stdin);
// 客戶輸出exit,退出
if(strncasecmp(msg.content, EXIT, strlen(EXIT)) == 0){
isClientwork = 0;
}
// 的進程將信息寫入管道
else {
//清空發送緩存
memset(send_buf,0,BUF_SIZE);
//結構體轉換為字符串
memcpy(send_buf,&msg,sizeof(msg));
if( write(pipe_fd[1], send_buf, sizeof(send_buf)) < 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)
{
memset(recv_buf,0,sizeof(recv_buf));
//服務端發來消息
if(events[i].data.fd == sock)
{
//接受服務端廣播消息
int ret = recv(sock, recv_buf, BUF_SIZE, 0);
//清空結構體
memset(&msg,0,sizeof(msg));
//將發來的消息轉換為結構體
memcpy(&msg,recv_buf,sizeof(msg));
// ret= 0 服務端關閉
if(ret == 0) {
cout << "Server closed connection: " << sock << endl;
close(sock);
isClientwork = 0;
} else {
cout << msg.content << endl;
}
}
//子進程寫入事件發生,父進程處理并發送服務端
else {
//父進程從管道中讀取數據
int ret = read(events[i].data.fd, recv_buf, BUF_SIZE);
// ret = 0
if(ret == 0)
isClientwork = 0;
else {
// 將從管道中讀取的字符串信息發送給服務端
send(sock, recv_buf, sizeof(recv_buf), 0);
}
}
}//for
}//while
}
// 退出進程
Close();
}
ClientMain.cpp
#include "Client.h"
// 客戶端主函數
// 創建客戶端對象后啟動客戶端
int main(int argc, char *argv[]) {
Client client;
client.Start();
return 0;
}
ServerMain.cpp
#include "Server.h"
// 服務端主函數
// 創建服務端對象后啟動服務端
int main(int argc, char *argv[]) {
Server server;
server.Start();
return 0;
}
最后是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