网络编程——即时通信系统

mac2026-09-30  7

1、socket套接字创建流程图

2、相关函数介绍

socket函数
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int socket(int domain, int type, int protocol); domain: AF_INET 这是大多数用来产生 socket 的协议,使用 TCP 或 UDP 来传输,用 IPv4 的地址 AF_INET6 与上面类似,不过是来用 IPv6 的地址 AF_UNIX 本地协议,使用在 Unix 和 Linux 系统上,一般都是当客户端和服务器在同一台及其上的时候使用 type: SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的 socket 类型,这个 socket 是使用 TCP 来进行传输。 SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用 UDP 来进行它的连接。 SOCK_SEQPACKET 该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。 SOCK_RAW socket 类型提供单一的网络访问,这个 socket 类型使用 ICMP 公共协议。(ping、traceroute 使用该协议) SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序 protocol: 传 0 表示使用默认协议。 返回值: 成功:返回指向新创建的 socket 的文件描述符,失败:返回-1,设置 errno 函数功能: socket()打开一个网络通讯端口,如果成功的话,就像 open()一样返回一个文件描述符,应用程序可以像读写文 件一样用 read/write 在网络上收发数据,如果 socket()调用出错则返回-1。对于 IPv4,domain 参数指定为 AF_INET。 对于 TCP 协议,type 参数指定为 SOCK_STREAM,表示面向流的传输协议。如果是 UDP 协议,则type参数指定为 SOCK_DGRAM,表示面向数据报的传输协议。protocol一般设置为0。
bind函数

服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可 以向服务器发起连接,因此服务器需要调用 bind 绑定一个固定的网络地址和端口号。

#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); sockfd: socket 文件描述符 addr: 构造出 IP 地址加端口号 addrlen: sizeof(addr)长度 返回值: 成功返回 0,失败返回-1, 设置 errno 函数功能: bind()的作用是将参数 sockfd 和 addr 绑定在一起,使 sockfd 这个用于网络通讯的文件描述符监听 addr 所描述 的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,addr 参数实际上可以接受多种协议的 sockaddr 结构体,而它们的长度各不相同,所以需要第三个参数 addrlen 指定结构体的长度。

例如,以下为一般格式

struct sockaddr_in servaddr; bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(6666); /* 首先将整个结构体清零,然后设置地址类型为 AF_INET,网络地址为 INADDR_ANY,这个宏表示本地的任意 IP 地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个 IP 地址,这样设置可以在所有的 IP 地址上监听,直 到与某个客户端建立了连接时才确定下来到底用哪个 IP 地址,端口号为 6666。 */
listen函数
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int listen(int sockfd, int backlog); sockfd: socket 文件描述符 backlog: 排队建立 3 次握手队列和刚刚建立 3 次握手队列的链接数和 函数功能: 典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的 accept()返回并接受这 个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未 accept 的客户端就处于连接等待状态,listen()声明 sockfd 处于监听状态,并且最多允许有 backlog 个客户端处于连接待状态,如果接收到更多的连接请求就忽略。 listen()成功返回 0,失败返回-1。
accept函数
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); sockdf: socket 文件描述符 addr: 传出参数,返回链接客户端地址信息,含 IP 地址和端口号 addrlen: 传入传出参数(值-结果),传入 sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小 返回值: 成功返回一个新的 socket 文件描述符,用于和客户端通信,失败返回-1,设置 errno 函数功能: 三方握手完成后,服务器调用 accept()接受连接,如果服务器调用 accept()时还没有客户端的连接请求,就阻塞 等待直到有客户端连接上来。addr 是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen 参数是一 个传入传出参数(value-result argument),传入的是调用者提供的缓冲区 addr 的长度以避免缓冲区溢出问题,传出 的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给 addr 参数传 NULL,表示不关心 客户端的地址。

以下为通用格式:

while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); ...... close(connfd); } /* 整个是一个 while 死循环,每次循环处理一个客户端连接。由于 cliaddr_len 是传入传出参数,每次调用 accept() 之前应该重新赋初值。accept()的参数 listenfd 是先前的监听文件描述符,而 accept()的返回值是另外一个文件描述符 connfd,之后与客户端之间就通过这个 connfd 通讯,最后关闭 connfd 断开连接,而不关闭 listenfd,再次回到循环 开头 listenfd 仍然用作 accept 的参数。accept()成功返回一个文件描述符,出错返回-1。 */
connect函数
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); sockdf: socket 文件描述符 addr: 传入参数,指定服务器端地址信息,含 IP 地址和端口号 addrlen: 传入参数,传入 sizeof(addr)大小 返回值: 成功返回 0,失败返回-1,设置 errno 函数功能: 客户端需要调用 connect()连接服务器,connect 和 bind 的参数形式一致,区别在于 bind 的参数是自己的地址, 而 connect 的参数是对方的地址。connect()成功返回 0,出错返回-1。
epoll函数用来实现高并发

epoll 是 Linux 下多路复用 IO 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的 情况下的系统 CPU 利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重 新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍 历那些被内核 IO 事件异步唤醒而加入 Ready 队列的描述符集合就行了。 目前 epoll 是 linux 大规模并发网络程序中的热门首选模型。 epoll 除了提供 select/poll 那种 IO 事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered), 这就使得用户空间程序有可能缓存 IO 状态,减少 epoll_wait/epoll_pwait 的调用,提高应用程序效率。

下面来看一下它的原理:

1、创建一个 epoll 句柄,参数 size 用来告诉内核监听的文件描述符的个数,跟内存大小有关。 #include <sys/epoll.h> int epoll_create(int size) size:监听数目(内核参考值) 返回值:成功:非负文件描述符;失败:-1,设置相应的 errno 2、控制某个 epoll 监控的文件描述符上的事件:注册、修改、删除。 #include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) epfd: 为 epoll_creat 的句柄 op: 表示动作,用 3 个宏来表示: EPOLL_CTL_ADD (注册新的 fd 到 epfd), EPOLL_CTL_MOD (修改已经注册的 fd 的监听事件), EPOLL_CTL_DEL (从 epfd 删除一个 fd); event: 告诉内核需要监听的事件 struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; EPOLLIN : 表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭) EPOLLOUT: 表示对应的文件描述符可以写 EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来) EPOLLERR: 表示对应的文件描述符发生错误 EPOLLHUP: 表示对应的文件描述符被挂断; EPOLLET: 将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的 EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里 返回值:成功:0;失败:-1,设置相应的 errno 3、等待所监控文件描述符上有事件的产生,类似于 select()调用。 #include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout) events: 用来存内核得到事件的集合,可简单看作数组。 maxevents: 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create()时的 size, timeout: 是超时时间 -1:阻塞 0: 立即返回,非阻塞 >0:指定毫秒 返回值: 成功返回有多少文件描述符就绪,时间到时返回 0,出错返回-1

3、基于TCP/IP协议的C/S模型

建立连接的“三次握手”和断开连接的“四次挥手”可以参看这里,点击进入

4、服务端的搭建(Linux平台)

Common.h

#pragma once #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 "欢迎来到聊天室!你的ID是:Client #%d" //其他用户收到消息的前缀 #define SERVER_MESSAGE "ClientID %d 说 >> %s" #define SERVER_PRIVATE_MESSAGE "Client %d 悄悄地对你说 >> %s" #define SERVER_PRIVATE_ERROR_MESSAGE "Client %d 不在聊天室" //退出系统 #define EXIT "EXIT" //提醒你是聊天室中唯一的客户 #define CAUTION "当前聊天室中只有一个人" /* LT模式时,事件就绪时,假设对事件没做处理,内核会反复通知事件就绪 ET模式时,事件就绪时,假设对事件没做处理,内核不会反复通知事件就绪 */ //注册新的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!\n\n"); } //定义信息结构,在服务端和客户端之间传送 struct Msg { int type; int fromID; int toID; char content[BUF_SIZE]; };

Server.h

#pragma once #include <string.h> #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; };

ServerMain.cpp

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

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; //epoll fd epfd=0; } //初始化服务器端并启动监听 void Server::Init() { cout<<"初始化服务端..."<<endl; //创建监听socket listener=socket(PF_INET,SOCK_STREAM,0); if(listener<0) { perror("无监听"); exit(-1); } //绑定地址 if(bind(listener,(struct sockaddr *)&serverAddr,sizeof(serverAddr))<0) { perror("绑定错误"); exit(-1); } //监听 int ret=listen(listener,5); if(ret<0) { perror("监听错误"); exit(-1); } cout<<"开始监听:"<<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; //置字节字符串前n个字节为零且包括‘\0’ 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."<<endl; cout<<"现在有"<<clients_list.size()<<"个客户在聊天室"<<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) { //格式化发送的消息内容 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 connect 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<<"添加新客户端"<<clientfd<<"到epoll"<<endl; cout<<"现在有"<<clients_list.size()<<"个客户端在聊天室"<<endl; //服务端发送欢迎信息 cout<<"欢迎"<<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(); }

5、客户端的搭建(Linux平台)

Client.h

#pragma once #include <string.h> #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; };

ClientMain.cpp

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

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; //epoll 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<<"请输入exit退出聊天室"<<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<<"服务端关闭连接:"<<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); } } } } } //退出进程 Close(); }

6、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

7、测试结果展示

群聊测试

私聊测试

最新回复(0)