系统流程概览
main函数
对于一个服务器程序来说,因为要为外部的客户端程序提供网络服务,也就是进行数据的读写,这就必然需要一个 socket 文件描述符,只有拥有了文件描述符 C/S 两端才能通过 socket 套接字进行网络通信,而初始化这样一个 socket 服务端程序套接字接口的流程我们将其封装成了一个函数:initListenFd(int port);
在经过该函数的操作之后,我们会得到一个用于服务器程序进行网络通信的 socket 文件描述符。
此时我们就应该启动我们的服务器程序了,也就是使用创建好的 server socket 去和外部的客户端程序进行网络通信,但是如果不借助 IO 多路复用技术的话,我们的服务器程序每次就只能和一个 client 进行通信,这显然是不合理的。
因此我们这里引入 epoll 这种广受欢迎的 IO 多路复用技术来完成服务器程序的构建,我们将这个构建过程封装为一个函数:epollRun(server_fd);
initListenFd(int port) 函数
在这个函数中主要是完成对于服务器端程序用于进行网络通信的 socket 套接字初始化,流程比较固定也比较简单:
主要由五个步骤:
1、创建监听的套接字
主要是使用 socket 函数,这是系统调用socket,用于创建一个新的套接字。socket函数有三个参数,分别指定了地址族(Address Family)、套接字类型(Socket Type)和协议(Protocol)。
AF_INET
: 第一个参数AF_INET指定了地址族为IPv4。这意味着创建的套接字将使用IPv4地址进行通信。
SOCK_STREAM
: 第二个参数SOCK_STREAM指定了套接字类型为面向连接的字节流套接字,即TCP套接字。这意味着该套接字将使用TCP协议进行数据传输,它提供了一套面向连接的、可靠的、有序的数据传输服务。
0
: 第三个参数通常为0,表示自动选择该地址族和套接字类型所对应的默认协议。对于AF_INET和SOCK_STREAM,这个默认协议就是TCP。
2、设置端口复用
setsockopt() 函数用于设置套接字的选项。
第一个参数 lfd 是之前创建的套接字文件描述符。
第二个参数 SOL_SOCKET 是选项所在的协议层,这里表示选项应用于套接字层。
第三个参数 SO_REUSEADDR 是选项的名称,它允许本地地址和端口号被重用。这对于服务器程序特别有用,因为它允许服务器在重启时立即绑定到相同的端口上,而不必等待之前绑定的套接字超时(为了避免服务器等待 2MSL 超时时间后才能重用之前的 ip 和端口,因此需要在程序重启后立即重用之前绑定的地址和端口)。
第四个参数 &opt 是指向一个变量的指针,该变量包含要设置的值。在这个例子中,opt应该是一个整数变量,通常被设置为1来启用 SO_REUSEADDR 选项。
第五个参数sizeof opt是opt变量的大小。
返回值ret是一个整数,表示操作的成功或失败。如果操作成功,返回0;如果失败,返回-1。
3、绑定端口
在网络编程中,当你调用bind()函数来将一个套接字(socket)与一个特定的IP地址和端口号绑定时,你需要传递一个指向sockaddr结构体的指针作为参数。然而,sockaddr是一个通用的结构体,它本身并不包含足够的信息来直接表示一个IP地址和端口号。因此,在实际使用中,我们通常会使用sockaddr_in(对于IPv4)或sockaddr_in6(对于IPv6)这样的结构体,它们包含了sockaddr结构体以及额外的信息,如IP地址和端口号。
这是一个历史上遗留下来的设计问题,因此只需记得我们会使用 sockaddr_in 类型而不是 sockaddr 类型即可。
4、设置监听
listen函数的第二个参数在网络编程中扮演着重要的角色。这个参数通常被称为backlog,它定义了操作系统可以接受但尚未被应用程序(如服务器)通过accept调用接受的传入连接请求的数量。具体来说,backlog参数指定了内核中未完成连接队列(也称为完全连接队列或accept队列)的最大长度。
虽然你可以指定backlog的大小,但操作系统可能会对这个值进行限制。在Linux系统中,backlog的实际最大值可能受到/proc/sys/net/core/somaxconn文件内容的限制(一般是 128 )。如果指定的backlog大于这个值,它会被默默地截断到这个值。
5、返回已经初始化好的服务器程序的 socket 文件描述符
//初始化用于监听的套接字
int initListenFd(unsigned short port){
//1、创建监听的 fd
int lfd = socket(AF_INET,SOCK_STREAM,0);
if(lfd == -1){
perror("socket");
return -1;
}
//2、设置端口复用
int opt = 1;
int ret = setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof opt);
if(lfd == -1){
perror("setsockopt");
return -1;
}
//3、绑定端口
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
ret = bind(lfd,(struct sockaddr*)&addr,sizeof addr);
if(ret == -1){
perror("bind");
return -1;
}
//4、设置监听
ret = listen(lfd,128);
if(ret == -1){
perror("listen");
return -1;
}
//5、返回fd
return lfd;
}
epollRun(server_fd) 函数
这个函数主要有三个步骤,依然是比较流程化的固定行为:
1、创建 epoll 红黑树的实例
因此如果使用的是 epoll_create()函数的话,函数里面这个参数随便填一个大于 0 的数即可。
2、将创建的 epoll 实例挂到红黑树上
这个也比较流程化,epoll 在内核中管理的是数据类型是 struct epoll_event 类型,因此我们需要创建一个这个类型的对象然后把我们的刚刚前面创建好的 server_fd 放到这个对象中同时进行需要监听何种事件的设置,最后通过 epoll_ctl() 函数将其挂到我们的 epoll 实例上即可。
3、检测挂载在epoll实例上所要监听文件描述符的事件的发生
这个同样是流程化的操作,唯一要注意的是在服务器程序开始运行后,epoll_create 创建的 epoll 实例 epfd 就是内核红黑树中的根节点,绑定了服务器程序 ip 和 端口的 server_fd 以及许多其它的客户端连接所创建的 client_fd 都会被挂载在这颗红黑树上:
因为我们实际上对 client_fd 监听的也只是读事件,而在代码中我们不难看到对于 server_fd 我们监听的也是读事件,因此看起来概念上似乎二者没有区别,然而意义上则很有不同。
对于 server_fd 来说,其如果有读事件发生,则含义是有外部的客户端程序请求与服务器程序建立TCP连接;
对于 client_fd 来说,其如果有读事件发生,则含义是已经建立的客户端程序与服务器程序的TCP连接上有从客户端发送来的数据需要进行读取。
所以从下面代码中我们可以看到,当使用 epoll_wait() 函数后,其会返回一个数字,这个数字代表了对于所有挂载在红黑树上的文件描述符中被监听到有读事件发生的文件描述符的个数,同时这些文件描述符对应的存储在红黑树上的 struct epoll_event 结构体也会被内核存储到我们下面代码中 struct epoll_event evs[1024]; 的结构体数组中进行保存。
因此我们直接对这个结构体进行遍历,从其中取出每一个文件描述符与我们绑定了服务器程序的 server_fd 进行比较,如果相同,则表示当前所遍历到的这个文件描述符就是我们的 server_fd,而这个文件描述符能被我们遍历到则说明是有新的客户端程序向服务器程序发起了TCP连接(读事件发生),因此我们就进行 TCP 连接,这个进行新的客户端连接的过程我们封装为一个函数:acceptClient(lfd,epfd);
而如果不是我们的 server_fd 的话,那就说明一定是之前就已经建立了 TCP 连接的 client_fd,这个 client_fd 能被我们遍历到则说明在这条连接中有从客户端发来的数据请求需要我们进行读取(读事件发生),因此我们就进行数据读取,而这个数据读取的过程我们也封装为一个函数:recvHttpRequest(lfd,epfd);
//启动 epoll 服务器程序
int epollRun(int lfd){
printf("Server is started.\n");
//1、创建epoll红黑树的实例
int epfd = epoll_create(1);
if(epfd == -1){
perror("epoll_create");
return -1;
}
//2、将 epoll 实例挂到树上
struct epoll_event ev;
ev.data.fd = lfd;
ev.events = EPOLLIN;
int ret = epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev);
if(ret == -1){
perror("epoll_ctl");
return -1;
}
//3、检测事件发生
//这个evs检测数组是用来存储epoll内核所检测到的就绪事件的
//这样我们就可以通过这个检测数组知道有哪些事件已经就绪了
struct epoll_event evs[1024];
//计算 evs 数组的大小
int size = sizeof(evs) / sizeof(struct epoll_event);
while(1){
int num = epoll_wait(epfd,evs,size,-1);
for(int i=0; i<num; ++i){
int fd = evs[i].data.fd;
//如果这个就绪读事件的fd的含义是有新连接到来
if(fd == lfd){
//建立新连接 accept
acceptClient(lfd,epfd);
}
//否则就是用于数据通信的文件描述符
else{
//处理对端发送来的数据
recvHttpRequest(fd,epfd);
}
}
}
}
acceptClient(lfd,epfd); 函数
本函数用于和客户端建立连接。
本身也比较简单,基本就只有两个步骤。
1、为新来的请求建立连接
这个就比较简单啦,就是调用 accept() 接收一下即可,接收完了之后就会产生一个建立连接了的 client_fd 了。
2、将这个新建立连接的 socket 挂载到 epoll 红黑树上进行监听
同样是监听读事件嘛,比较简单,但是我们为了进一步榨取服务器程序的性能,这里我们将 epoll 技术默认的水平触发模式更改为边缘触发模式。
边缘触发(Edge Triggered, ET)通常比水平触发(Level Triggered, LT)效率要高。这主要体现在以下几个方面:
因此这里为了更进一步的压榨服务器程序的性能,我们修改 epoll 默认的水平触发方式,使用边缘触发。
同时将刚刚建立的文件描述符的文件属性由阻塞改为非阻塞,边缘触发+非阻塞的配合方式能够更进一步的榨干服务器程序性能。
//和客户端建立连接的函数
int acceptClient(int lfd,int epfd){
//1、为新来的请求建立连接
int cfd = accept(lfd,NULL,NULL);
printf("New client is connected.\n");
if(cfd == -1){
perror("accept");
return -1;
}
//2、将刚刚建立的连接添加到epfd红黑树上
//在添加之前,将cfd的属性改为非阻塞的
//因为epoll的边缘非阻塞模式的效率是最高的
int flag = fcntl(cfd,F_GETFL);//先得到cfd的文件属性
flag |= O_NONBLOCK; //在原来的文件属性中追加一个非阻塞属性
fcntl(cfd,F_SETFL,flag); //再设置回cfd的文件属性当中
struct epoll_event ev;
ev.data.fd = cfd;
ev.events = EPOLLIN | EPOLLET; //边缘模式监听读事件
int ret =epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&ev);
if(ret == -1){
perror("epoll_ctl");
return -1;
}
return 0;
}
recvHttpRequest(lfd,epfd); 函数
在这个函数中要做的就是将客户端发送过来的所有的数据都接收到本地。
对于处理这个函数中的逻辑很简单,就两步:
1、读取来自客户端的 http 报文数据,这就需要我们理解 http 协议的请求数据格式,如下图:
2、通过读取数据的情况分类讨论一下各自要进行的动作
情况1:数据读完了,那么就要进行解析接收到的请求行的操作了,这个被封装为一个函数:
parseRequestLine(const char* line,int cfd);
情况2:如果 recv 返回 0,那么表示客户端已经断开连接,那就从epoll树上删除该 socket 连接
情况3:读取数据出错了,打印日志信息
其它要注意的点在代码注释中已经注明:
//接收 http 请求
int recvHttpRequest(int cfd,int epfd){
//在这个函数中要做的就是将客户端发送过来的所有的数据都接收到本地
char tmp[1024] = {0}; //相当于水瓢,将客户端的数据从tmp转存到buf中
char buf[4096] = {0}; //真正存数据的大水缸
//因为我们设置epoll事件通知的模式是边缘非阻塞,
//因此epoll检测到文件描述符对应的读事件之后就只会给我们通知一次
//因此我们需要在得到这个通过之后一次性把所有的数据都读出来
int len = 0, total = 0;
while((len = recv(cfd,tmp,sizeof tmp,0)) > 0){
//确保数据总量total加上新读取的数据量len的值不会超出缓冲区
if(total+len < sizeof buf){
//这时候再进行数据拷贝
memcpy(buf+total,tmp,len);
}
//如果超出了buf大小的数据是可以丢弃的,因为对于get请求来说
//最重要的是请求行,也就是只要知道请求方式、要请求的资源即可
total += len;
}
//判断数据是否被接收完毕
//因为套接字是非阻塞的,当数据接收完毕之后,recv函数还会继续读数据
//继续读数据但是没有数据会返回什么呢?返回 -1
//而如果是阻塞的话,数据读完时recv就会被阻塞住的
//另外读数据如果失败的话也是会返回 -1 的
//既然都会返回 -1,那么怎么判断是读完了还是出现了错误呢?
//因此这里有一个细节,如果是数据读完的话对应的errno会有一个值,
//同理如果是读取出错errno则会有另外一个值
if(len == -1 && errno == EAGAIN){
//说明已经将客户端发来的数据处理完毕了
//现在开始进行请求的http协议进行解析
//解析请求行
char* pt = strstr(buf,"\r\n"); //先取出请求行
int reqLen = pt - buf; //获得请求行长度
buf[reqLen] = '\0'; //在请求行的最后加个\0就可以从请求报文数据中截取出请求行的内容
//然后调用一下解析请求行的函数即可完成对请求行的解析
parseRequestLine(buf,cfd);
}
else if(len == 0){
//客户端断开了连接
epoll_ctl(epfd,EPOLL_CTL_DEL,cfd,NULL);
close(cfd);
}
else{
perror("recv");
}
return 0;
}
parseRequestLine(const char* line,int cfd); 函数
在 recvHttpRequest 函数中我们会将缓存了 http 请求数据的 buf(字符串)传递给 parseRequestLine 函数,专门用来解析请求行的信息。
对于传进来的请求行,要将其解析成三个部分:请求方式、客户端请求的静态资源、客户端所使用的http协议版本。
如何拆?对于 http 协议而言,请求行的每一部分之间会有一个空格,我们就通过这个空格来做做文章,这里介绍一个 sscanf()
函数可以比较方便的帮助我们完成这个事情:
然后我们要注意,对于这个项目而言,我们只接收解析 get 方法的 http 请求,对于 post 我们这里就省略了,因为比较复杂,我们这个项目主要也是为了深入理解 Reactor 这种高并发服务器模型的,如果确实有需要并且希望添加 post 请求的解析那么我相信在掌握了本项目的这些基础之上再去添加丰富这个项目是完全可以做到的。
最后关于这个函数还有一件事情需要完成,对于 get 请求,其所需要的资源总共有两种:目录或者是文件。
因此需要分情况进行处理,而对于服务器程序所提供的资源目录我们可以在程序启动的时候把其写死也可以让用户把这个资源目录给传进来,这里我们采用后者,因此需要修改一下 main 函数。
上面的逻辑应该比较好懂,唯一比较难理解的是为什么我们需要将当前服务器进程切换到用户指定的静态资源目录下面。
在服务器端编程中,服务器进程的工作目录(Current Working Directory, CWD)通常指的是进程启动时所在的目录,或者之后通过程序代码显式更改的目录。服务器进程将工作目录切换到服务器提供的静态资源目录下面,主要是出于以下几个考虑:
因为进程启动时,其所访问的文件路径(特别是使用相对路径时)通常是相对于该进程的工作目录(Current Working Directory, CWD)来解析的。
工作目录是进程在文件系统中当前的工作位置,它决定了进程如何解释和执行使用相对路径的文件访问请求。当进程尝试打开一个文件时,如果使用的是相对路径,那么操作系统就会从工作目录开始,按照提供的相对路径来查找并访问该文件。
例如,如果进程的工作目录是 /home/user/project
,并且它尝试使用相对路径data/config.txt
来打开一个文件,那么操作系统会在/home/user/project/data/
目录下查找名为config.txt
的文件。
然而,值得注意的是,并非所有文件访问都依赖于工作目录。如果进程使用绝对路径(即以根目录/开始的路径)来访问文件,那么无论工作目录是什么,文件访问都会按照绝对路径指定的位置进行。
此外,一些程序或库可能会提供自己的路径解析机制,这些机制可能不完全依赖于操作系统的工作目录。但是,在大多数情况下,特别是在处理静态资源或配置文件时,使用相对路径并依赖于工作目录是一种常见的做法。
因此,了解并控制进程的工作目录对于确保文件访问的正确性和安全性是非常重要的。在服务器编程中,特别是在处理静态资源和配置文件时,可能需要显式地更改工作目录以确保文件访问的正确性。
在服务器进程切换到静态资源的目录后,对于 get 请求的请求行格式如下:
请求方式 资源路径 http协议版本号
get /xxx/1.jpg http/1.1
其中资源目录:/xxx/1.jpg
中开头的斜杠是固定的,它代表了客户端请求的服务器程序提供的资源文件目录的根目录。而 xxx/1.jpg
就是这个根目录下的子目录 xxx
和子文件 1.jpg
。
因此我们在服务器程序中此时就可以使用相对路径 ./
来表示这个服务器程序提供的静态资源的根目录同时也就是上面 get 请求格式中资源目录的 /
了。
具体的逻辑在代码中都很好理解,直接看代码吧:
//解析请求行
int parseRequestLine(const char* line,int cfd){
//解析请求行,主要将三部分切出来:请求方式、请求资源、http协议版本
char method[12] = {0}; //请求方式
char path[1024] = {0}; //请求的资源路径
//开始进行子字符串的提取,也就是解析操作
sscanf(line,"%[^ ] %[^ ]",method,path);
printf("method is %s, resource path is %s \n",method,path);
//不区分大小写的比较解析出来的是否是get请求
//不处理post请求,太复杂了项目主要是为了理解高并发服务器模型
//因此就省略了 post 请求的解析了
if(strcasecmp(method,"get") != 0){
return -1;
}
//调用解码函数,将请求行中的特殊字符转义回去
decodeMsg(path,path);
//如果是get请求,那么就开始解析静态资源(目录或者是文件)
//get请求格式:get /xxx/1.jpg http/1.1
char* file = NULL;
//先判断一下客户端访问的资源路径是否为服务器提供的静态资源路径的根目录
if(strcmp(path,"/") == 0){
//如果是,那么我们就让file转化为 ./,表示静态资源的根目录
//然后我们把file传进读写函数中进行处理
file = "./";
}
else{
//不是 / 根目录的话,那么要访问的资源就是 xxx/1.jpg,这明显是一个相对路径
//其等同于 ./xxx/1.jpg
//那么我们让path指针地址往后偏移一个char单位即可略过字符 '/'
file = path + 1;
}
printf("file is : %s \n",file);
//此时有了文件资源地址file之后,我们要做的就是判断这个file是文件还是目录
//通过 OS 的 stat API 我们可以拿到文件属性,通过文件属性判断file所代表的是文件还是目录
struct stat st;
int ret = stat(file,&st);
printf("文件属性返回 ret == %d\n",ret);
if(ret == -1){
//文件不存在,那么回复404页面
sendHeadMsg(cfd,404,"Not Found",getFileType(".html"),-1);
sendFile("404.html",cfd);
//访问资源造成404的话,那么下面的事情就不需要再做了,那么return即可
return 0;
}
//如果存在,那么判断文件类型,Linux提供了一个S_ISDIR来帮助判断
if(S_ISDIR(st.st_mode)){
//如果是目录,那就把所请求资源目录下的内容发送给客户端
//Content-length不知道大小的话就填-1让浏览器自己决定即可
sendHeadMsg(cfd,200,"Ok",getFileType(".html"),-1);
sendDir(file,cfd);
}
else{
//否则就是文件,那么就把文件内容发送给客户端
sendHeadMsg(cfd,200,"Ok",getFileType(file),st.st_size);
sendFile(file,cfd);
}
return 0;
}
sendFile(const char* fileName,int cfd); 函数
就三步:
1、打开指定的文件
2、然后从里面读数据,读一部分发一部分
为什么这样可行呢?
因为发送数据的时候我们底层使用的是 TCP 协议,TCP 的特点之一就是面向连接的、流式的传输协议,所谓的流式传输协议就是通信的两端只要建立了连接之后,只要连接还建立着那么我们就可以把这个数据一点一点发过去,因为流并没有数据块大小的限定,一次性发多少其实都没问题(只要硬件支持的话),因此我们可以读一部分发一部分。
另外在这个函数中我们还使用了断言 assert 技术,这是一种比较严格的错误检查:
另外在这个函数当中,我们还介绍了一个更加高效的用于发送数据的 API,sendfile():
我们还使用了一个 lseek() 系统调用:
lseek 函数是一个在 UNIX/Linux 系统编程中广泛使用的系统调用,它用于改变读写一个文件时读写指针(也称为文件偏移量)的位置。这个函数允许程序显式地设置下一次读或写操作在文件中的起始位置。以下是对 lseek 函数的详细解释:
具体看代码:
//发送文件
int sendFile(const char* fileName,int cfd){
//1、打开文件
int fd = open(fileName,O_RDONLY);
//使用断言进行严苛的判断,断言如果出现错误那么直接程序挂掉
assert(fd > 0);
#if 0
//下面这是一种解决方案,然而我们还有更加简单的方式
while(1){
char buf[1024];
int len = read(fd,buf,sizeof buf);
if(len > 0){
send(cfd,buf,len,0);
//流量控制,客户端解析服务端发送的数据需要时间
//因此我们每次发送完就停几微秒即可,避免客户端来不及接收数据
//这非常重要
usleep(10);
}
else if(len == 0){
//说明文件读完,那么直接break
break;
}
else{
//否则就是读文件出现了异常
perror("read");
}
}
#else
int size = lseek(fd,0,SEEK_END);
//上面这行代码将fd的读写指针拉到了文件尾部
//单我们发送文件时还需要对其进行读数据操作呢
//因此这里我们还要将这个文件的读写指针给重置回开始处
lseek(fd,0,SEEK_SET);
printf("文件大小:%d\n",size);
off_t offset = 0;
while(offset < size){
//sendfile的第三个参数是偏移量,其会被执行两个操作
//1、发送数据之前,sendfile根据该偏移量开始读文件数据
//2、发送数据之后,sendfile会在底层自动更新该偏移量
//假如数据为1000个字节,第一次sendfile发送了100个字节,此时offset就0变成了100
//那么下一次再进行读的时候,sendfile就会从第100个字节处开始读取
int ret = sendfile(cfd,fd,&offset,size-offset);
if(ret > 0){
printf("发送数据量: %d \n",ret);
}
if (ret == -1 && errno == EAGAIN){
printf("没数据...\n");
continue;
}
else if(ret == -1){
perror("sendfile");
break;
}
}
close(fd);
#endif
return 0;
}
sendHeadMsg(int cfd,int status…); 函数
为什么会需要有这个函数?这需要来看一下 http 协议响应的数据格式:
可以看见总共有四部分组成,而我们的 sendFile() 函数所发送的数据正是 http 返回数据格式中的第四部分响应体的部分,相当于只返回了 http 协议的一个部分,如果没有状态行和响应头的话,我们服务器程序的数据也是无法发送到客户端的,因此我们这里要编写这个 sendHeadMsg() 函数来完成状态行和响应头的编写。
也就是说如果我们想要发送一个文件,那么在调用 sendFile 函数之前要先调用 sendHeadMsg 函数才行。
同理不管是发送什么数据吧,都需要在发送目录函数之前先调用 sendHeadMsg 函数才行(不然就没办法封装成完整的 http 协议的报文格式了)。
//发送响应头(状态行+响应头)
int sendHeadMsg(int cfd,int status,const char* desc,const char* type,int length){
//封装状态行
char buf[4096] = {0};
//拼接字符串
sprintf(buf,"http/1.1 %d %s\r\n",status,desc);
//封装响应头
sprintf(buf+strlen(buf),"content-type: %s\r\n",type);
sprintf(buf+strlen(buf),"content-length: %d\r\n\r\n",length);
//注意,http响应数据格式还有第三部分空行,这里我们一并加在了上面这行代码中
send(cfd,buf,strlen(buf),0);
return 0;
}
getFileType() 函数
这个函数的主要作用就是根据服务器程序要发送的文件数据的类型,然后返回该文件类型相对应的 http 响应头中的 Content-type 值。
//根据文件名后缀获得其对应的Content-type值
const char* getFileType(const char* name)
{
// a.jpg a.mp4 a.html
// 自右向左查找‘.’字符, 如不存在返回NULL
const char* dot = strrchr(name, '.');
if (dot == NULL)
return "text/plain; charset=utf-8"; // 纯文本
if (strcmp(dot, ".html") == 0 || strcmp(dot, ".htm") == 0)
return "text/html; charset=utf-8";
if (strcmp(dot, ".jpg") == 0 || strcmp(dot, ".jpeg") == 0)
return "image/jpeg";
if (strcmp(dot, ".gif") == 0)
return "image/gif";
if (strcmp(dot, ".png") == 0)
return "image/png";
if (strcmp(dot, ".css") == 0)
return "text/css";
if (strcmp(dot, ".au") == 0)
return "audio/basic";
if (strcmp(dot, ".wav") == 0)
return "audio/wav";
if (strcmp(dot, ".avi") == 0)
return "video/x-msvideo";
if (strcmp(dot, ".mov") == 0 || strcmp(dot, ".qt") == 0)
return "video/quicktime";
if (strcmp(dot, ".mpeg") == 0 || strcmp(dot, ".mpe") == 0)
return "video/mpeg";
if (strcmp(dot, ".vrml") == 0 || strcmp(dot, ".wrl") == 0)
return "model/vrml";
if (strcmp(dot, ".midi") == 0 || strcmp(dot, ".mid") == 0)
return "audio/midi";
if (strcmp(dot, ".mp3") == 0)
return "audio/mpeg";
if (strcmp(dot, ".pdf") == 0)
return "application/pdf";
if (strcmp(dot, ".ogg") == 0)
return "application/ogg";
if (strcmp(dot, ".pac") == 0)
return "application/x-ns-proxy-autoconfig";
if (strcmp(dot, ".mp4") == 0)
return "video/mp4";
//return "text/plain; charset=utf-8";
//如果上面的文件类型都不包含的话,那么就默认执行下载
//application/octet-stream用于表示未知或者二进制文件
return "application/octet-stream; charset=utf-8";
}
sendDir() 函数
那么目录要如何发送呢?
发送目录之前我们需要搞明白,如果是发送目录那我们发送的这个目录到底需要包含什么内容?
因此我们需要把要发送目录下的所有的文件的名字给读出来,读出来之后把这些文件的名字以及文件的类型发送给客户端即可。
客户端拿到这些东西会展示在自己的页面上,因此在这个函数中我们要做的事情就是在服务器端程序上遍历这个要发送的目录。
如何遍历 Linux 系统的目录结构?
有两种方式。
第一种是使用:opendir() + readdir() + closedir()
三个函数组合完成遍历。
第二种是使用:scandir()
函数。
从数量上也可以明显看出,使用第二种方式遍历目录的方式要比第一种简单太多,因此这里我们使用第二种。
其中对于 scandir 函数的第四个参数来说,我们一般不需要自己写,因为 Linux 给我们提供了两个标准的排序函数:
int alphasort(const struct dirent** a,const struct dirent** b);
int versionsort(const struct dirent**a,const struct dirent** b);
一般情况下我们直接使用上面的 alphasort 就行,直接将 alphasort 函数的名字写入 scandir 函数的第四个参数中即可。
在这个函数中还有最后一个注意点就是客户端需要按什么方式来展示我们响应的目录列表。
众所周知,在浏览器中都是通过 html 标签来显示的。因此我们只要将需要发送给客户端的目录数据包装成一个 html 网页的方式来组织起来,将这个 html 数据块发送给客户端浏览器即可。
我们的 html 数据块组织形式如下:
<html>
<head>
<title>test</title>
</head>
<body>
<table>
<tr>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
</tr>
</table>
</body>
</html>
完整代码如下:
//发送目录
int sendDir(const char* dirName,int cfd){
//拼接html网页用的缓存空间
char buf[4096] = {0};
//用dirName做标签页的标题
sprintf(buf,"<html><head><title>%s</title></head><body><table>",dirName);
struct dirent** namelist;
int num = scandir(dirName,&namelist,NULL,alphasort);
for(int i=0; i<num;++i){
// 取出文件名
// namelist 指向的是一个指针数组struct dirent* tmp[]
char* name = namelist[i]->d_name;
//取出后也还是要判断是文件名还是目录名
struct stat st;
/*
* 要注意这里的name只是一个名字,它只能表示一个相对路径,比如 xxx
* 直接传入这个name能正确吗?显然是不对的
* 因为我们要指定相对路径的话就必须要把dirName一起指定进来才行
* 因为dirName才是真正的相对路径,name只是dirName目录里的一个子目录或者子文件
* 因此我们要再次对字符串进行拼接,把dirName和name拼接到一起然后再传给stat进行判断
* 拼接后的结果才是一个合理的正确的相对路径(这样才能定位到正确的目录或者文件)
*/
char subPath[1024] = {0};
sprintf(subPath,"%s/%s",dirName,name);
stat(subPath,&st);
//是目录的话
if(S_ISDIR(st.st_mode)){
//添加一个a标签<a href="">name</a>使得在浏览器上点击一下目录名就能够进行跳转
//注意如果要跳转到某个目录里面,那么在这个目录名的后面要加上一个斜杠 /
//有斜杠就表示我们要跳转到某个目录里面,没有斜杠的话就表示要访问的是某个文件
sprintf(buf+strlen(buf)
,"<tr><td><a href=\"%s/\">%s</a></td><td>%ld</td></tr>"
,name,name,st.st_size);
}
//是文件的话
else{
sprintf(buf+strlen(buf)
,"<tr><td><a href=\"%s\">%s</a></td><td>%ld</td></tr>"
,name,name,st.st_size);
}
//拼接完成后发送出去,这里依然是读一部分就发一部分
send(cfd,buf,strlen(buf),0);
//为了方便下一轮的循环,这里要清空缓冲区的内容
memset(buf,0,sizeof(buf));
//namelist[i]是struct dirent*类型的指针元素,被分配了内存空间那么就需要回收
free(namelist[i]);
}
//还剩最后的结束标签
sprintf(buf,"</table></body></html>");
send(cfd,buf,strlen(buf),0);
//同理,namelist是个二级指针,也被分配了内存空间因此也需要回收
free(namelist);
return 0;
}
decodeMsg(); 与 hexToDec(); 函数
在这两个函数中,我们解决一下浏览器无法访问带特殊字符的文件的问题。
而在我们这个项目中,主要是没办法解决带中文字符的问题。
比如我们要访问的是 代码.h
这个资源:
但是如果我们在服务器上打印一下就会发现:
我们的中文字符被系统转义了,变了一串编码字符串,那这样的话显然我们的服务器程序就没办法通过文件名 open 出正确的文件描述符,那么访问也就会变成 404 页面。
原因在于:对于 http 协议中的 get 请求而言,请求行中是不支持有特殊字符的!
如果有特殊字符就必须要转义,比如说中文。怎么转义?系统会把这些特殊字符给转换成 UTF-8,UTF-8 在 Linux 里面对应的是三个字符,每个字符都有一个字符值。
在 Linux 里面有一个命令可以帮助我们查看这种问题,但是得自己安装一下(我的是 Ubuntu 系统):
sudo apt install unicode
安装完之后我们就可以进行一个查看了,对于上面的 代码.h
文件:
对比后不难发现,代
这个字对应 Linux 中 UTF-8 的三位编码:e4 bb a3
,而 码
字也是同理。
解决方案也很简单,只需要将这些被系统编码了的特殊字符,给转义回来即可。
这种转义字符的工具函数网上是非常多的,直接大致看看 CV 即可:
// 将字符转换为整形数
int hexToDec(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return 0;
}
// 解码
// to 存储解码之后的数据, 传出参数, from被解码的数据, 传入参数
void decodeMsg(char* to, char* from)
{
for (; *from != '\0'; ++to, ++from)
{
// isxdigit -> 判断字符是不是16进制格式, 取值在 0-f
// Linux%E5%86%85%E6%A0%B8.jpg
if (from[0] == '%' && isxdigit(from[1]) && isxdigit(from[2]))
{
// 将16进制的数 -> 十进制 将这个数值赋值给了字符 int -> char
// B2 == 178
// 将3个字符, 变成了一个字符, 这个字符就是原始数据
*to = hexToDec(from[1]) * 16 + hexToDec(from[2]);
// 跳过 from[1] 和 from[2] 因此在当前循环中已经处理过了
from += 2;
}
else
{
// 字符拷贝, 赋值
*to = *from;
}
}
*to = '\0';
}
最后版本的 main 函数
int main(int argc,char** argv){
//解决mp3/mp4文件在线播放问题
signal(SIGPIPE,SIG_IGN);
//argv[0]是可执行程序的名字,argv[1]就是传进来的第一个参数
//我们让用户传进来两个参数:
//第一个是port端口,第二个则是服务器访问的静态资源目录的文件路径
if(argc < 3){
printf("./a.out port path\n");
return -1;
}
//port端口号:字符串转整形
unsigned short port = atoi(argv[1]);
//将当前服务器进程切换到用户指定的静态资源目录下面
chdir(argv[2]);
//初始化用于监听的套接字
int lfd = initListenFd(port);
//启动服务器程序
epollRun(lfd);
return 0;
}
本项目服务器程序所用到的函数汇总
#pragma once
#include <stdio.h>
//初始化用于监听套接字
int initListenFd(unsigned short port);
//启动 epoll 服务器程序
int epollRun(int lfd);
//和客户端建立连接的函数
int acceptClient(int lfd,int epfd);
//接收http请求
//客户端发送完数据就会断开连接,也就意味着我们不需要对该文件描述符进行监听了
//因此我们还需要epoll实例,通过这个epoll实例去红黑树上删除这个客户端所对应的文件描述符
int recvHttpRequest(int cfd,int epfd);
//解析请求行
int parseRequestLine(const char* line,int cfd);
//发送文件
int sendFile(const char* fileName,int cfd);
//发送响应头(状态行+响应头)
/*
状态行就两部分:
参数status:http状态码
参数desc:http状态描述
响应头就是一堆键值对,这里我们只写比较重要的两个:一个是Content-type,一个Content-length
参数type:响应头Content-type的值
参数length:响应头Content-length的值
*/
int sendHeadMsg(int cfd,int status,const char* desc,const char* type,int length);
//根据文件名后缀获得其对应的响应头中的Content-type值
const char* getFileType(const char* name);
//发送目录
int sendDir(const char* dirName,int cfd);
//解决Linux转义get请求中特殊字符的编码问题
int hexToDec(char c);
void decodeMsg(char* to,char* from);
加入多线程技术
如果要加入多线程技术,我们需要思考的就是在什么地方需要进行子线程的创建。
从前文可知,在服务器器端一共有两类文件描述符,一类是用来监听新连接的,一类是用于监听和客户端数据通信的。
而前面这一类在服务器端有且仅有一个,所以我们在主线程里将它创建出来之后,就不需要再做其它用来监听新连接的描述符的创建了。因为通过这唯一一个的监听新连接的文件描述符,服务器就能够接收到客户端的连接请求并且和多个客户端建立连接了。
对于 epollRun() 这个函数,其核心就是下面这一段永真循环:
while(1){
int num = epoll_wait(epfd,evs,size,-1);
for(int i=0; i<num; ++i){
int fd = evs[i].data.fd;
//如果这个就绪读事件的fd的含义是有新连接到来
if(fd == lfd){
//建立新连接 accept
acceptClient(lfd,epfd);
}
//否则就是用于数据通信的文件描述符
else{
//处理对端发送来的数据
recvHttpRequest(fd,epfd);
}
}
}
在 while 循环内只做两件事情,如果是新连接则建立新连接,如果是有数据通信则进行数据处理。
因此对于这两个函数,不管是 acceptClient 函数还是 recvHttpRequest 函数我们都可以把它们放入子线程中进行操作,也就是说,我们需要在这两个函数调用的位置创建子线程,然后把这两个函数分别传递给子线程让子线程来处理这两个动作。
逻辑像下面这样,以 acceptClient 为例:
//建立新连接 accept
acceptClient(lfd,epfd);
pthread_creat(tid,NULL,acceptClient,线程参数);
对于上面的代码,对于 pthread_create() 函数来说,tid 是线程 id,这个显然是我们要创建的,第二个参数线程属性我们是不需要特别设置什么的设置为 NULL 即可,第三个参数是一个函数指针,也就是线程函数的入口,很明显就是我们的 acceptClient,对于第四个参数则是传递线程入口函数所需要的参数,在这里也就是我们的 acceptClient 函数,很明显其需要两个参数,但是这个参数只能填一个值,怎么办?
将线程入口函数所需要的参数封装成一个结构体即可!
因此我们定义结构体如下:
struct FdInfo{
int fd;
int epfd;
pthread_t tid;
};
有了上面这个结构体之后,我们就可以修改 epollRun 函数中的永真循环部分如下:
while(1){
int num = epoll_wait(epfd,evs,size,-1);
for(int i=0; i<num; ++i){
struct FdInfo* info = (struct FdInfo*)malloc(sizeof(struct FdInfo));
int fd = evs[i].data.fd;
info->fd = fd;
info->epfd = epfd;
//如果这个就绪读事件的fd的含义是有新连接到来
if(fd == lfd){
//建立新连接 accept
//acceptClient(lfd,epfd);
pthread_create(&info->tid,NULL,acceptClient,info);
}
//否则就是用于数据通信的文件描述符
else{
//处理对端发送来的数据
//recvHttpRequest(fd,epfd);
pthread_create(&info->tid,NULL,recvHttpRequest,info);
}
}
}
然后我们需要去修改一下我们的 acceptClient 和 recvHttpRequest 函数,因为它们按照我们之前的函数定义是不满足 pthread_create 函数对于线程入口函数要求的,因此我们去修改一下它们的定义:
除了函数声明,函数的定义也需要更改嗷:
首先是 acceptClient 函数:
//和客户端建立连接的函数
//int acceptClient(int lfd,int epfd){
void* acceptClient(void* arg){
struct FdInfo* info = (struct FdInfo*)arg;
//1、为新来的请求建立连接
int cfd = accept(info->fd,NULL,NULL);
printf("New client is connected.\n");
if(cfd == -1){
perror("accept");
return NULL;
}
//2、将刚刚建立的连接添加到epfd红黑树上
//在添加之前,将cfd的属性改为非阻塞的
//因为epoll的边缘非阻塞模式的效率是最高的
int flag = fcntl(cfd,F_GETFL);//先得到cfd的文件属性
flag |= O_NONBLOCK; //在原来的文件属性中追加一个非阻塞属性
fcntl(cfd,F_SETFL,flag); //再设置回cfd的文件属性当中
struct epoll_event ev;
ev.data.fd = cfd;
ev.events = EPOLLIN | EPOLLET; //边缘模式监听读事件
int ret =epoll_ctl(info->epfd,EPOLL_CTL_ADD,cfd,&ev);
if(ret == -1){
perror("epoll_ctl");
return NULL;
}
printf("acceptClient threadId: %ld\n",info->tid);
//当客户端与服务器建立连接之后这块空间就可以回收了
//其中的lfd和epfd不可以关闭,因为还要用呢
free(info);
return NULL;
}
然后是 recvHttpRequest 函数:
//接收 http 请求
//int recvHttpRequest(int cfd,int epfd){
void* recvHttpRequest(void* arg){
struct FdInfo* info = (struct FdInfo*)arg;
//在这个函数中要做的就是将客户端发送过来的所有的数据都接收到本地
char tmp[1024] = {0}; //相当于水瓢,将客户端的数据从tmp转存到buf中
char buf[4096] = {0}; //真正存数据的大水缸
//因为我们设置epoll事件通知的模式是边缘非阻塞,
//因此epoll检测到文件描述符对应的读事件之后就只会给我们通知一次
//因此我们需要在得到这个通过之后一次性把所有的数据都读出来
int len = 0, total = 0;
while((len = recv(info->fd,tmp,sizeof tmp,0)) > 0){
//确保数据总量total加上新读取的数据量len的值不会超出缓冲区
if(total+len < sizeof buf){
//这时候再进行数据拷贝
memcpy(buf+total,tmp,len);
}
//如果超出了buf大小的数据是可以丢弃的,因为对于get请求来说
//最重要的是请求行,也就是只要知道请求方式、要请求的资源即可
total += len;
}
//判断数据是否被接收完毕
//因为套接字是非阻塞的,当数据接收完毕之后,recv函数还会继续读数据
//继续读数据但是没有数据会返回什么呢?返回 -1
//而如果是阻塞的话,数据读完时recv就会被阻塞住的
//另外读数据如果失败的话也是会返回 -1 的
//既然都会返回 -1,那么怎么判断是读完了还是出现了错误呢?
//因此这里有一个细节,如果是数据读完的话对应的errno会有一个值,
//同理如果是读取出错errno则会有另外一个值
if(len == -1 && errno == EAGAIN){
//说明已经将客户端发来的数据处理完毕了
//现在开始进行请求的http协议进行解析
//解析请求行
char* pt = strstr(buf,"\r\n"); //先取出请求行
int reqLen = pt - buf; //获得请求行长度
buf[reqLen] = '\0'; //在请求行的最后加个\0就可以从请求报文数据中截取出请求行的内容
//然后调用一下解析请求行的函数即可完成对请求行的解析
parseRequestLine(buf,info->fd);
}
else if(len == 0){
//客户端断开了连接
epoll_ctl(info->epfd,EPOLL_CTL_DEL,info->fd,NULL);
close(info->fd);
}
else{
perror("recv");
}
//打印一下线程id
printf("recvMsg threadId: %ld\n",info->tid);
//数据通信结束,关闭用于通信的fd,epfd不关闭,因为还要使用呢
//如果尝试关闭一个已经关闭的文件描述符,
//大多数Unix-like系统(包括Ubuntu)会忽略这个操作,
//并返回错误(通常是EBADF,表示“Bad file descriptor”)。
//然而,这种错误通常不会导致程序崩溃,除非程序没有正确处理这个错误。
close(info->fd);
//回收info堆空间资源
free(info);
return 0;
}
总结以及源码地址
至此,本项目作为一个简易的、基于 epoll+多线程 技术实现的 Web Server 项目就结束了。
源码地址已经开源在我的 gitee 仓库,欢迎 star!
链接在这里: epoll+多线程 实现的简易版本 Web Server 项目;