一、项目概述
本项目实现了一个简单的基于 TCP 协议的群聊应用,包含客户端和服务端两部分。客户端支持用户输入用户名、发送聊天消息、退出群聊;服务端利用 epoll
实现 I/O 多路复用,支持多客户端连接,能处理用户加入、退出、聊天消息的广播。
二、核心功能模块
(一)客户端模块
- 连接初始化
- 通过
init_tcp_send
函数创建 TCP 套接字,填充服务端地址(IP 和端口),为连接服务端做准备。 - 代码示例:
- 通过
c
int init_tcp_send() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket error");
return -1;
}
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(SER_PORT);
seraddr.sin_addr.s_addr = inet_addr(SER_IP);
return sockfd;
}
- 消息发送线程(
send_msg
)- 功能:循环读取用户输入,封装成
Msg_t
结构体(区分聊天消息MSG_CHAT
和退出消息MSG_QUIT
),通过 TCP 套接字发送给服务端。 - 关键逻辑:
- 用
strncpy
安全复制用户名到消息结构体,避免缓冲区溢出; - 检测到
.quit\n
输入时,切换消息类型为MSG_QUIT
,发送后关闭套接字并退出程序。
- 用
- 代码示例:
- 功能:循环读取用户输入,封装成
c
void *send_msg(void *arg) {
Msg_t msg = {0};
msg.type = MSG_CHAT;
strncpy(msg.name, username, sizeof(msg.name) - 1);
msg.name[sizeof(msg.name) - 1] = '\0';
int sockfd = *((int *)arg);
while (1) {
memset(msg.buff, 0, sizeof(msg.buff));
fgets(msg.buff, sizeof(msg.buff), stdin);
if (strcmp(msg.buff, ".quit\n") == 0) {
msg.type = MSG_QUIT;
send(sockfd, &msg, sizeof(msg), 0);
close(sockfd);
exit(0);
}
ssize_t cnt = send(sockfd, &msg, sizeof(msg), 0);
if (cnt < 0) {
perror("send error\n");
break;
}
}
return NULL;
}
- 消息接收线程(
recv_msg
)- 功能:循环接收服务端广播的消息,根据消息类型(
MSG_JOIN
MSG_QUIT
MSG_CHAT
)格式化输出。 - 关键逻辑:
- 收到
cnt == 0
时,判定服务端断开连接,关闭套接字并退出; - 依据
msg.type
区分系统消息(用户加入、退出)和聊天消息,分别处理显示。
- 收到
- 代码示例:
- 功能:循环接收服务端广播的消息,根据消息类型(
c
void *recv_msg(void *arg) {
Msg_t msg = {0};
int sockfd = *((int *)arg);
while (1) {
memset(&msg, 0, sizeof(msg));
ssize_t cnt = recv(sockfd, &msg, sizeof(msg), 0);
if (cnt < 0) {
perror("recv error\n");
break;
} else if (cnt == 0) {
printf("服务器已断开连接\n");
close(sockfd);
exit(0);
}
if (msg.type == MSG_JOIN) {
printf("[系统消息] %s 加入群聊\n", msg.name);
} else if (msg.type == MSG_QUIT) {
printf("[系统消息] %s 退出群聊\n", msg.name);
} else if (msg.type == MSG_CHAT) {
printf("%s: %s", msg.name, msg.buff);
}
}
return NULL;
}
- 主函数流程
- 步骤:
- 读取用户名并清除换行符;
- 初始化套接字、连接服务端;
- 发送
MSG_JOIN
消息告知服务端用户加入; - 创建发送和接收线程,等待线程结束后关闭套接字。
- 代码示例:
- 步骤:
c
int main(int argc, const char*argv[]) {
printf("请输入你的用户名: ");
fgets(username, sizeof(username), stdin);
username[strcspn(username, "\n")] = '\0';
int sockfd = init_tcp_send();
if (sockfd < 0) {
perror("socket error");
return -1;
}
int cnt = connect(sockfd, (struct sockaddr*)&seraddr, sizeof(seraddr));
if (cnt < 0) {
perror("connect error");
return -1;
}
Msg_t join_msg = {0};
join_msg.type = MSG_JOIN;
strncpy(join_msg.name, username, sizeof(join_msg.name) - 1);
join_msg.name[sizeof(join_msg.name) - 1] = '\0';
int ret = send(sockfd, &join_msg, sizeof(join_msg), 0);
if (ret < 0) {
perror("send join error");
return -1;
}
pthread_t tid[2];
pthread_create(&tid[0], NULL, send_msg, &sockfd);
pthread_create(&tid[1], NULL, recv_msg, &sockfd);
pthread_join(tid[0], NULL);
pthread_join(tid[1], NULL);
close(sockfd);
}
(二)服务端模块
- 连接初始化(
init_tcp_ser
)- 功能:创建 TCP 套接字,绑定服务端地址(IP 和端口),并开始监听客户端连接。
- 代码示例:
c
int init_tcp_ser() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket error");
return -1;
}
struct sockaddr_in seraddr;
seraddr.sin_family = AF_INET;
seraddr.sin_port = htons(SER_PORT);
seraddr.sin_addr.s_addr = inet_addr(SER_IP);
int ret = bind(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
if (ret < 0) {
perror("bind error");
return -1;
}
ret = listen(sockfd, 100);
if (ret < 0) {
perror("listen error");
return -1;
}
return sockfd;
}
epoll
相关操作(epoll_add_fd
epoll_del_fd
)epoll_add_fd
:将文件描述符(套接字)添加到epoll
实例,监听指定事件(如EPOLLIN
读事件 )。epoll_del_fd
:从epoll
实例中删除文件描述符,不再监听其事件。- 代码示例:
c
int epoll_add_fd(int epfds, int fd, uint32_t events) {
struct epoll_event ev;
ev.events = events;
ev.data.fd = fd;
int ret = epoll_ctl(epfds, EPOLL_CTL_ADD, fd, &ev);
if (ret < 0) {
perror("epoll_ctl add error");
return -1;
}
return 0;
}
int epoll_del_fd(int epfds, int fd) {
int ret = epoll_ctl(epfds, EPOLL_CTL_DEL, fd, NULL);
if (ret < 0) {
perror("epoll_ctl del error");
return -1;
}
return 0;
}
- 客户端连接管理(
save_connfd
del_connfd
)save_connfd
:将新连接的客户端套接字描述符保存到数组connfds_g
,用于后续广播消息。del_connfd
:从connfds_g
中删除指定客户端套接字描述符,通常在客户端断开或退出时调用。- 代码示例:
c
int save_connfd(int *connfds_g, int fd) {
if (total_fd_g >= MAX_FD_CNT || total_fd_g < 0) {
return -1;
}
connfds_g[total_fd_g] = fd;
total_fd_g++;
return 0;
}
int del_connfd(int *connfds_g, int fd) {
int i;
for (i = 0; i < total_fd_g; ++i) {
if (connfds_g[i] == fd) {
break;
}
}
if (i >= total_fd_g) {
printf("connfds_g Not found %d\n", fd);
return -1;
}
for (; i < total_fd_g - 1; ++i) {
connfds_g[i] = connfds_g[i + 1];
}
total_fd_g--;
if (total_fd_g < 0) {
return -1;
}
return 0;
}
- 主循环与事件处理
- 功能:
- 用
epoll_wait
等待客户端事件(新连接、消息接收); - 处理新客户端连接,将其套接字加入
epoll
监听和连接管理数组; - 接收客户端消息,根据消息类型(
MSG_JOIN
MSG_QUIT
MSG_CHAT
)处理(打印、广播、清理连接 )。
- 用
- 代码示例:
- 功能:
c
int main(int argc, const char *argv[]) {
Msg_t mymsg;
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int sockfd = init_tcp_ser();
if (sockfd < 0) {
return -1;
}
int epfds = epoll_create(MAX_FD_CNT);
if (epfds < 0) {
perror("epoll_create error");
return -1;
}
epoll_add_fd(epfds, sockfd, EPOLLIN);
struct epoll_event evs[MAX_FD_CNT];
while (1) {
int cnt = epoll_wait(epfds, evs, MAX_FD_CNT, -1);
if (cnt < 0) {
perror("epoll_wait error");
return -1;
}
for (int i = 0; i < cnt; ++i) {
if (sockfd == evs[i].data.fd) {
int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
perror("accept error");
return -1;
}
epoll_add_fd(epfds, connfd, EPOLLIN);
save_connfd(connfds_g, connfd);
} else {
memset(&mymsg, 0, sizeof(mymsg));
ssize_t size = recv(evs[i].data.fd, &mymsg, sizeof(Msg_t), 0);
if (size < 0) {
perror("recv error");
epoll_del_fd(epfds, evs[i].data.fd);
del_connfd(connfds_g, evs[i].data.fd);
close(evs[i].data.fd);
continue;
} else if (size == 0) {
printf("客户端断开连接\n");
epoll_del_fd(epfds, evs[i].data.fd);
del_connfd(connfds_g, evs[i].data.fd);
close(evs[i].data.fd);
continue;
}
if (mymsg.type == MSG_JOIN) {
printf("[%s] 加入群聊!\n", mymsg.name);
} else if (mymsg.type == MSG_QUIT) {
printf("[%s] 退出群聊!\n", mymsg.name);
epoll_del_fd(epfds, evs[i].data.fd);
del_connfd(connfds_g, evs[i].data.fd);
close(evs[i].data.fd);
} else if (mymsg.type == MSG_CHAT) {
printf("%s: %s", mymsg.name, mymsg.buff);
}
for (int j = 0; j < total_fd_g; ++j) {
if (evs[i].data.fd != connfds_g[j]) {
ssize_t send_size = send(connfds_g[j], &mymsg, sizeof(Msg_t), 0);
if (send_size < 0) {
perror("send error");
close(connfds_g[j]);
del_connfd(connfds_g, connfds_g[j]);
continue;
}
}
}
}
}
}
close(sockfd);
return 0;
}
三、关键技术点
- TCP 套接字编程 :客户端和服务端通过
socket
connect
bind
listen
accept
等函数建立 TCP 连接,实现可靠的字节流传输。 - 多线程(客户端):客户端用 pthread 库创建两个线程,分别处理消息发送和接收,实现输入输出异步操作。
- I/O 多路复用(服务端) :服务端利用
epoll
高效管理多个客户端连接,同时监听新连接和消息事件,提升并发处理能力。 - 结构体与消息协议 :定义
Msg_t
结构体统一消息格式,包含类型、用户名、内容,两端通过该结构体解析和封装消息,实现协议约定。 - 内存与连接管理 :服务端用数组管理客户端连接,配合
epoll
实现连接的添加、删除,避免资源泄漏和无效连接干扰。
四、常见问题与解决方案
- 消息乱码 :因
fgets
读取换行符或strcpy
未正确处理字符串结束符,解决方案是用strcspn
清除换行符,strncpy
结合手动补\0
保证字符串安全。 - 服务端收不到聊天消息 :可能服务端未正确解析
MSG_CHAT
类型消息或广播逻辑错误,需检查服务端消息类型判断和广播循环,确保Msg_t
结构体两端一致。 - 客户端连接后服务端无响应 :排查网络(IP、端口是否正确,防火墙是否拦截 )、
epoll
事件注册(是否添加新连接到epoll
)、消息接收逻辑(recv
返回值处理 )。 - 资源泄漏 :客户端线程未正确关闭套接字、服务端未及时清理断开的客户端连接,需在退出或断开时调用
close
、从epoll
和连接数组删除相关描述符。