hello !大家好呀! 欢迎大家来到我的Linux高性能服务器编程系列之项目实战——仿QQ聊天程序源码剖析,在这篇文章中,你将会学习到如何利用Linux网络编程技术来实现一个简单的聊天程序,并且我会给出源码进行剖析,以及手绘UML图来帮助大家来理解,希望能让大家更能了解网络编程技术!!!
希望这篇文章能对你有所帮助,大家要是觉得我写的不错的话,那就点点免费的小爱心吧!(注:这章对于高性能服务器的架构非常重要哟!!!)
目录
一.项目介绍
二.服务器代码剖析
2.1 头文件和相关数据声明
2.2 服务器连接准备代码
2.3 服务器处理逻辑代码
2.3 客户端代码剖析
一.项目介绍
像ssh这样的登录服务通常要同时处理网络连接和用户输入,这也可以使用I/O复用来实现。我们以poll为例实现一个简单的聊天室程序,以阐述如何使用I/O 复用技术来同时处理网络连接和用户输入。该聊天室程序能让所有用户同时在线群聊,它分为客户端和服务器两个部分。其中客户端程序有两个功能:一是从标准输入终端读入用户数据,并将用户数据发送至服务器;二是往标准输出终端打印服务器发送给它的数据。服务器的功能是接收,客户数据,并把客户数据发送给每一个登录到该服务器上的客户端(数据发送者除外)。下面我们依次给出客户端程序和服务器程序的代码。
二.服务器代码剖析
2.1 头文件和相关数据声明
#define _GNU_SOURCE 1#include<t_stdio.h>#include<t_file.h>#include<sys/types.h>#include<sys/socket.h>#include<netinet/in.h>#include<arpa/inet.h>#include<assert.h>#include<unistd.h>#include<string.h>#include<stdlib.h>#include<sys/poll.h>#include<fcntl.h>#include<errno.h>#define user_limit 5 //最大客户连接数量#define buffer_size 64#define fd_limit 65535 //最大文件描述符数量struct client_data{//创建一个客户地址结构体 struct sockaddr_in address ; char * write_buf; char buf[buffer_size];};int setnonblocking (int fd){//将文件描述符改为非阻塞模式 int old_option = fcntl(fd , F_GETFL); int new_option = old_option | O_NONBLOCK;// 添加非阻塞选项 fcntl(fd , F_SETFL , new_option);//设置 return old_option;}
这部分代码包含了头文件,定义了一些宏,以及一个用于存储客户端数据的结构体,这个结构体是为了服务器更好控制来自客户端的socket套接字,以及灵活控制对socket套接字的读写,然后还定义了setnonblocking()函数来将传入的文件描述符利用fcntl函数改为非阻塞模式,方便服务器进行监听。
2.2 服务器连接准备代码
这段代码是服务器端程序的入口和初始化部分。下面是逐行的解释:
int main(int argc , char *argv[]){ if(argc <= 2)//如果参数太少 { printf("usage :%s ip_address port_number\n",basename(argv[0])); return 1; }
这段代码检查命令行参数的数量。如果参数少于两个(程序名称和IP地址/端口号),则打印使用说明并退出程序。
const char * ip = argv[1] ;// 提取ip地址 int port = atoi(argv[2]); //提取端口号
从命令行参数中提取服务器的IP地址和端口号。
struct sockaddr_in address ; //服务器地址 bzero(&address ,sizeof(address));//清空 address.sin_family = AF_INET; inet_pton(AF_INET , ip ,&address.sin_addr);//设置ip address.sin_port = htons(port); //设置端口号
这里创建了一个sockaddr_in
结构体来存储服务器的地址信息,并使用bzero
函数将其清零。然后设置地址族为AF_INET
(IPv4),使用inet_pton
函数将点分十进制的IP地址转换为网络字节序的格式,并存储在sin_addr
字段中。最后,将端口号从主机字节序转换为网络字节序并存储在sin_port
字段中。
int listenfd = socket(PF_INET ,SOCK_STREAM , 0);//创建监听套接字 assert(listenfd >=0);
创建一个TCP套接字(SOCK_STREAM
)用于监听客户端连接,并检查套接字是否创建成功。
int ret = bind(listenfd , (struct sockaddr*)&address , sizeof(address));//绑定 assert(ret !=-1);
将套接字绑定到之前设置的服务器地址上,并检查绑定操作是否成功。
ret = listen(listenfd ,5);//最多同时监听五个 assert(ret!=-1);
调用listen
函数,使套接字进入监听状态,并设置最大同时连接数为5。然后检查监听操作是否成功。
//创建user数组,放入多个客户对象,并且使用socket的值可以直接用来索引(作为数组下标)连接对应的client_data对象 struct client_data * user = malloc(fd_limit * sizeof(struct client_data)); //为了提高poll性能,限制用户数量 struct pollfd *fds = malloc(sizeof(struct pollfd) * 6); int user_counter = 0;//计算客户连接数量 int i=0; for( i = 1 ; i<=user_limit ; ++i){//对每个fds数据初始化 fds[i].fd = -1; fds[i].events =0; }
这段代码分配了两个数组:
user
数组用于存储客户端数据,fds
数组用于poll
函数。user
数组的大小被设置为fd_limit
,这是一个预定义的最大文件描述符数量。fds
数组的大小被设置为6,这是因为服务器程序只监听一个套接字(listenfd
),而其余的用于客户端连接。user_counter
用于跟踪当前连接的客户端数量。fds
数组的其余元素被初始化为-1
,表示没有对应的文件描述符。
//初始化怕poll中第一个数据:监听套接字 fds[0].fd = listenfd; fds[0].events = POLLIN | POLLERR; fds[0].revents = 0;
最后,将监听套接字listenfd
添加到fds
数组中,并设置其监听的事件为可读事件(POLLIN
)和错误事件(POLLERR
)。revents
字段用于poll
函数返回时存储发生的事件,在这里初始化为0。
这段代码为服务器程序的后续操作设置了基础,包括套接字的创建和绑定,以及用于poll
函数的数组的初始化。
2.3 服务器处理逻辑代码
这段代码是服务器程序的主循环,它使用poll
系统调用来监控多个文件描述符(fds
数组)的事件。这个循环会一直运行,直到遇到错误或者被显式地退出。
while(1){
这是一个无限循环,服务器程序将一直运行直到出现错误或者执行了退出循环的操作。
ret = poll(fds , user_counter+1 , -1);//开始监听 if(ret <0) { printf("poll failed..\n"); break; }
在循环的顶部,调用
poll
函数来等待事件发生。fds
数组包含了所有需要监控的文件描述符,user_counter+1
表示总共有user_counter
个客户端连接加上监听套接字listenfd
。-1
表示poll
函数将阻塞直到至少有一个文件描述符上有事件发生。如果poll
调用失败(返回值小于0),则打印错误信息并退出循环。
for ( i =0 ; i <user_counter+1;i++){//每次对整个fds数组进行遍历处理 if(fds[i].fd==listenfd && (fds[i].revents & POLLIN)){//如果为第一个监听字符且发生可读事件时 struct sockaddr_in client_address;//创建一个新客户套接字 socklen_t client_addrlength = sizeof(client_address); int connfd = accept(listenfd ,(struct sockaddr*)&client_address ,&client_addrlength );//获取客户端套接字 if(connfd<0){//连接错误 printf("erron is:%dd\n"); continue; } if(user_counter >=user_limit){//用户太多 const char * info ="too many users\n"; printf("%s\n",info); send(connfd , info ,strlen(info) , 0);//发送错误给客户端 close(connfd); continue; } //对于新连接 ,我们要同时修改fds和users数组,user[connfd]即对应客户端数据 user_counter++;//客户数量加一 user[connfd].address = client_address; setnonblocking(connfd);//设置为非阻塞模式 fds[user_counter].fd = connfd;//最新数据放入数组 fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR; fds[user_counter].revents = 0; printf("comes a new user , now have %d user\n",user_counter); } // ... 其他事件处理逻辑 ... }
这个循环遍历
fds
数组中的每个文件描述符,检查它们是否有事件发生。对于每个事件,服务器程序执行相应的操作:如果监听套接字(
listenfd
)上有新的连接请求(POLLIN
事件),服务器接受新连接,并将新的文件描述符(connfd
)添加到fds
数组中。如果连接数超过限制(user_limit
),服务器会发送一个错误消息并关闭新连接。如果有任何文件描述符上有
POLLERR
事件,表示发生了错误,服务器会打印错误信息。如果有任何已连接的套接字上有
POLLIN
事件,表示有数据可读,服务器会读取数据并打印。如果有任何套接字上有
POLLRDHUP
事件,表示对方已经关闭了连接,服务器会关闭对应的连接并更新fds
数组。如果有任何套接字上有
POLLOUT
事件,表示可以写数据,服务器会发送数据(如果有数据要发送)。在循环结束后,服务器程序会继续执行下一次循环,等待更多的连接和事件。
在服务器端代码中,poll
函数用于监控多个文件描述符的事件。poll
函数的返回值表示有多少个文件描述符发生了事件,而每个文件描述符的事件类型存储在revents
字段中。下面是服务器端代码中使用poll
函数监控的不同事件类型及其解释:
if(fds[i].fd==listenfd && (fds[i].revents & POLLIN)){ // ... 接受连接逻辑 ...}else if(fds[i].revents & POLLERR){ // ... 错误处理逻辑 ...}else if(fds[i].revents & POLLIN){ // ... 读取数据逻辑 ...}else if(fds[i].revents & POLLRDHUP){ // ... 关闭连接逻辑 ...}else if(fds[i].revents & POLLOUT){ // ... 写数据逻辑 ...}
POLLIN: 这个事件表示文件描述符上有数据可读。对于服务器来说,这意味着有新的客户端连接请求或者已连接的客户端有数据发送过来。
POLLERR: 这个事件表示文件描述符发生了错误。可能是网络错误,也可能是其他类型的错误。服务器需要检查并处理这些错误。
POLLRDHUP: 这个事件表示文件描述符的读端已经被对方关闭。这通常发生在客户端突然断开连接的情况下。
POLLOUT: 这个事件表示文件描述符的写端准备好了,可以写入数据。对于服务器来说,这意味着它可以向客户端发送数据。
服务器程序通过检查
fds
数组中每个文件描述符的revents
字段,来确定发生了哪种事件,并相应地执行处理逻辑。如果没有任何事件发生,poll
函数会阻塞,直到至少有一个文件描述符上有事件发生。服务器程序通过这种方式可以高效地处理多个客户端连接。
2.3 客户端代码剖析
这段代码是一个简单的客户端程序,用于连接到一个服务器,并通过标准输入和输出与服务器进行通信
#define _GNU_SOURCE 1#include<t_stdio.h>#include<t_file.h>#include<sys/types.h>#include<sys/socket.h>#include<netinet/in.h>#include<arpa/inet.h>#include<assert.h>#include<unistd.h>#include<string.h>#include<stdlib.h>#include <sys/poll.h>#include<fcntl.h>#include<poll.h>#define buffer_size 64 //缓冲区大小
这段代码包含了必要的头文件和宏定义。_GNU_SOURCE
是一个宏,它用于启用一些GNU扩展,如splice
系统调用。
int main(int argc , char * argv[]){ if(argc <= 2)//如果参数太少 { printf("usage :%s ip_address port_number\n",basename(argv[0])); return 1; }
这段代码检查命令行参数的数量。如果参数少于两个(程序名称和IP地址/端口号),则打印使用说明并退出程序。
const char * ip = argv[1] ;// 提取ip地址 int port = atoi(argv[2]); //提取端口号
从命令行参数中提取服务器的IP地址和端口号。
struct sockaddr_in server_address ; //服务器地址 bzero(&server_address ,sizeof(server_address));//清空 server_address.sin_family = AF_INET; inet_pton(AF_INET , ip ,&server_address.sin_addr);//设置ip server_address.sin_port = htons(port); //设置端口号
创建一个sockaddr_in
结构体来存储服务器的地址信息,并使用bzero
函数将其清零。然后设置地址族为AF_INET
(IPv4),使用inet_pton
函数将点分十进制的IP地址转换为网络字节序的格式,并存储在sin_addr
字段中。最后,将端口号从主机字节序转换为网络字节序并存储在sin_port
字段中。
int sockfd = socket(PF_INET , SOCK_STREAM , 0 );//创建本地套接字 assert(socket >= 0 ); //判错 if(connect(sockfd , (struct sockaddr *)&server_address , sizeof(server_address)) < 0){//连接失败的话 printf("connection failed...\n"); close(sockfd); return 1; }
创建一个TCP套接字(SOCK_STREAM
)用于与服务器通信,并检查套接字是否创建成功。然后尝试连接到服务器。如果连接失败,打印错误信息并退出程序。
struct pollfd fds[2];//创建pollfd结构类型数组,注册标准输入和sockfd文件描述符上的可读事件 fds[0].fd = 0; fds[0].events = POLLIN ;//标准输入可读 fds[0].revents = 0; //实际发生事件,由内核填充 fds[1].fd = sockfd; fds[1].events = POLLIN | POLLRDHUP ;//标准输入可读 fds[1].revents = 0; //实际发生事件,由内核填充
创建一个pollfd
结构体数组,用于监控标准输入(0
)和套接字(sockfd
)上的可读事件。
while (1){ ret = poll(fds , 2 , -1); //最大被监听事件只有两个, 返回符合条件文件总数 if(ret < 0){//如果监听发生错误 printf("poll falied..\n"); break; }
在循环的顶部,调用poll
函数来等待事件发生。fds
数组包含了所有需要监控的文件描述符,2
表示总共有两个文件描述符(标准输入和套接字)。-1
表示poll
函数将阻塞直到至少有一个文件描述符上有事件发生。如果poll
调用失败(返回值小于0),则打印错误信息并退出循环。
if(fds[1].revents & POLLRDHUP){//假如发生了关闭对端连接 printf("server close the connection..\n"); break; } else if(fds[1].revents & POLLIN){//假如sockfd文件发生可读,则读取服务器传来数据 memset(readbuf , '\0' , buffer_size); recv(fds[1].fd , readbuf , buffer_size -1 , 0);//接收数据 if(ret <= 0){// 如果接收失败或对方关闭了连接 printf("server close the connection..\n"); break; } printf("%s\n",readbuf);//打印数据 }
if(fds[0].revents & POLLIN){//标准输入文件描述符可读,说明我们需要写入数据 ret = splice(0 , NULL , pipefd[1] , NULL ,32768 , SPLICE_F_MORE | SPLICE_F_MOVE);//从标准输入写入数据到管道写端 ret = splice(pipefd[0] , NULL , sockfd , NULL ,32768 , SPLICE_F_MORE | SPLICE ret = splice(0 , NULL , pipefd[1] , NULL ,32768 , SPLICE_F_MORE | SPLICE_F_MOVE);//从标准输入写入数据到管道写端 ret = splice(pipefd[0] , NULL , sockfd , NULL ,32768 , SPLICE_F_MORE | SPLICE_F_MOVE);//从管道读端将数据传输到sockfd printf("ok"); } } close(sockfd); return 0;}
这段代码检查套接字(
sockfd
)上的事件。如果套接字上有POLLRDHUP
事件,表示对方已经关闭了连接,服务器会关闭对应的连接并退出循环。如果套接字上有POLLIN
事件,表示有数据可读,服务器会读取数据并打印。这段代码是客户端程序主循环的最后一部分,它处理标准输入(
0
)上的数据,并通过管道(pipefd
)将其传输到套接字(sockfd
)上。
ret = splice(0 , NULL , pipefd[1] , NULL ,32768 , SPLICE_F_MORE | SPLICE_F_MOVE);
:splice
是一个系统调用,用于直接在内核空间复制数据,避免了用户空间和内核空间之间的数据拷贝。第一个参数是源文件描述符,这里是从标准输入0
。第二个参数是源文件描述符的偏移量,这里为NULL
,表示从文件开始读取。第三个参数是目标文件描述符,这里是对应的管道写端pipefd[1]
。第四个参数是目标文件描述符的偏移量,这里为NULL
,表示从文件开始写入。第五个参数是传输的数据量,这里为32768
,是一个系统定义的常量,表示最多传输32768字节。第六个参数是SPLICE_F_MORE
,表示这只是一个中间步骤,还有更多的数据要传输。第七个参数是SPLICE_F_MOVE
,表示传输的数据是从内核缓冲区直接移动,而不是复制。类似地,这段代码使用
ret = splice(pipefd[0] , NULL , sockfd , NULL ,32768 , SPLICE_F_MORE | SPLICE_F_MOVE);
:splice
系统调用来从管道读端pipefd[0]
传输数据到套接字sockfd
。打印"ok"表示数据传输成功。
printf("ok");
:循环继续执行,重复上述操作,直到连接被关闭或出现错误。
关闭套接字
close(sockfd);
:sockfd
,释放资源。程序返回0,表示正常退出。
return 0;
:这个客户端程序通过
poll
系统调用来监控标准输入和套接字的事件,并通过splice
系统调用来高效地传输数据。它使用管道作为中间缓冲区,以避免在用户空间和内核空间之间进行数据拷贝。
好啦!到这里这篇文章就结束啦,关于实例代码中我写了很多注释,如果大家还有不懂得,可以评论区或者私信我都可以哦!! 感谢大家的阅读,我还会持续创造网络编程相关内容的,记得点点小爱心和关注哟!