1. 简介

epoll 是linux下的一个 I/O 多路复用机制,用于高效地监听多个文件描述符上的 I/O 事件。相较于 select 和 poll,epoll 在处理大量连接时具有更好的性能。而HTTP是应用层协议,同其他应用层协议一样,是为了实现某一类具体应用的协议,并由某一运行在用户空间的应用程序来实现其功能。它也是基于B/S架构进行通信的,下面就结合epoll来实现一个能够给浏览器提供服务,供用户借助浏览器访问服务器主机中文件的B/S模式。

2. 代码实现

2.1 主函数

主函数主要负责把启动程序时,传进来的参数用变量来接收,并检测,当传进来的参数不满3个时,就会报错,最后传入端口,来调用一个监听函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char *argv[]){
//命令行参数获取 端口和server提供的目录
if(argc < 3){
printf("server port path\n");
}

//获取用户输入的端口
int port = atoi(argv[1]); //字符串转化为端口形式
//改变进程工作目录
int ret = chdir(argv[2]); //将你要打开的文件路径作为工作目录,到时候遍历目录内容时就比较方便了
if(ret != 0){
perror("chdir error");
exit(1);
}
//启动epoll监听
epoll_run(port);
return 0;
}

2.2 核心函数

这部分负责了小项目的大部分代码任务,监听连接、创建红黑树、向红黑树上添加监听描述符和通信描述符、以及建立连接后,对协议头的检查、创建发送协议头等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void epoll_run(int port){
int i;
struct epoll_event all_events[MAXSIZE];
//创建一个epoll红黑数根节点
int epfd = epoll_create(MAXSIZE);
if(epfd == -1){
perror("epoll_create error");
exit(1);
}
//创建监听描述符,并将其添加到红黑树上
int lfd = init_listen_fd(port, epfd); //这是一个自定义的函数,返回创建好的监听描述符(已添加到红黑树上)
while(1){
//监听节点对应事件 --->返回满足条件的事件个数
int ret = epoll_wait(epfd,all_events,MAXSIZE,-1); //阻塞等待,有事件满足的文件描述符都在all_events数组里
if(ret == -1){
perror("epoll_wait error");
exit(1);
}
for(i=0; i<ret; i++){ //开始遍历就绪的事件,一个一个处理
//只处理读事件,其它事件默认不处理
struct epoll_event *pev = &all_events[i]; //取出每一个事件
if(!(pev->events & EPOLLIN)){ //不是读事件
continue; //直接跳出该事件的处理继续下一个事件
}
if(pev->data.fd == lfd){ //是监听描述符的读事件,即有客户端请求连接
do_accept(lfd,epfd); //自定义函数,建立连接客户端,并挂上红黑树epoll
}else{ //是通信描述符的读事件,对端有信息发来
do_read(pev->data.fd, epfd); //自定义函数
}
}
}
}

下面是init_listen_fd()函数的实现,服务端绑定以及将监听的文件描述符挂到红黑数epoll上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int init_listen_fd(int port,int epfd){            //参数是端口和红黑树的根节点
//创建监听的套接字lfd
int lfd = socket(AF_INET,SOCK_STREAM,0);
if(lfd == -1){
perror("socket error");
exit(1);
}
//创建服务器地址结构ip+port
struct sockaddr_in srv_addr;
bzero(&srv_addr,sizeof(srv_addr));
srv_addr.sin_family = AF_INET;
srv_addr.sin_port = htons(port);
srv_addr.sin_addr.s_addr = htonl(INADDR_ANY); //INADDR_ANY指的是本地的ip
//端口复用
int opt = 1;
setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
//给lfd绑定地址结构
int ret = bind(lfd,(struct sockaddr *)&srv_addr,sizeof(srv_addr));
if(ret == -1){
perror("bind error");
exit(1);
}
//设置监听上限
ret = listen(lfd,128);
if(ret == -1){
perror("listen error");
exit(1);
}
//lfd添加到epoll数上,以监听连接的客户端信息
struct epoll_event ev;
ev.events = EPOLLIN; //设置读事件
ev.data.fd = lfd; //监听文件描述符
//将监听描述符的读事件添加到了红黑树上
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1){
perror("epoll_ctl add lfd error");
exit(1);
}
return lfd; //返回监听描述符
}

处理连接的函数:该函数负责与对端建立连接,由于是监听到有加内特描述符的读事件已就绪,所以执行该函数时,是不会阻塞的。与对端连接好连接后,就将与对端的通信描述符添加到epoll树上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void do_accept(int lfd,int epfd){
struct sockaddr_in clt_addr;
socklen_t clt_addr_len = sizeof(clt_addr);
int cfd = accept(lfd, (struct sockaddr*)&clt_addr, &clt_addr_len); //与对端连接,这里不会阻塞
if(cfd == -1){
perror("accept error");
exit(1);
}
//打印客户端ip+port
char client_ip[64] = {0};
//打印客户端的内容
printf("New client ip: %s, port: %d, cfd = %d\n", inet_ntop(AF_INET,&clt_addr.sin_addr.s_addr,client_ip,sizeof(client_ip)), ntohs(clt_addr.sin_port),cfd);
//设置cfd阻塞
int flag = fcntl(cfd,F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd,F_SETFL,flag);
//将新节点cfd挂到epoll数上,来监听对端通信
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 add cfd error");
exit(1);
}
}

通信函数实现:该函数负责的就是与对端的通信,其中需要判断对端发来的是什么类型请求,过程有点复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void do_read(int cfd, int epfd){
//读取一行http协议,拆分,获取get 文件名 协议号
char line[1024] = {0};
int len = get_line(cfd,line,sizeof(line)); //读http请求协议首行---> GET 客户需要获取的文件 http的协议号
if(len == 0){
printf("服务器,检查到客户端关闭....\n");
disconnect(cfd,epfd);
}else{ //有数据可以读
printf("================请求头================");
printf("请求行数据:%s",line);
while(1){ //清除剩下的数据,下次传来信息,可以继续读第一行
char buf[1024] = {0};
len = get_line(cfd,buf,sizeof(buf));
if(buf[0] == '\n'){
break;
}else if(len == -1){
break;
}
}
printf("===============The end===============\n");
//判断啥类型请求
//if(strncasecmp(method,"GET",3) == 0){ //确定用户传来的是啥类型命令--->这里是GET
if(strncasecmp("get", line, 3)==0){ //忽略大小写比较n个字符
http_request(line, cfd); //处理http请求
//关闭套接字,cfd从epoll上del
//disconnect(cfd, epfd);
}
}
}

在得到对端传来的请求后,通过请求的第一行来查看对端需要的文件是什么类型,并检查看当前文件有无对端需要的文件,不同情况。采取不同处理方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//处理http请求,判断文件是否存在
void http_request(const char* request, int cfd){ //请求http协议第一行;客户端fd
//拆分http请求行
char method[12], path[1024], protocol[12]; //三个字符数组分别装GET、文件名、HTTP协议号
sscanf(request, "%[^ ] %[^ ] %[^ ]", method, path, protocol); //正则表达式的方式获取各类容
printf("method = %s, path = %s, protocol = %s\n", method, path, protocol);

//转码 将不能识别的中文乱码 -->中文
//解码 将浏览器发来的文件名(unicode编码)转为中文,方便后台处理找到,后面回发给浏览器时,在对其进行编码
decode_str(path,path); //参数1是存解码好的内容,参数2是要解码的内容
//用户有请求是get /hello.txt HTTP1.1 ;无请求是get / HTTP1.1
char *file = path + 1; //取出 客户端要访问的文件名--->path是\video.mp4,而+1是为了移动下标位置,最前面的\不需要
//如果没有指定访问的资源,默认就显示资源目录中的内容
if(strcmp(path,"/")==0){ //如果是无请求,path就是/,file就是空 --->浏览器输入192.168.88.93:9527 --->就是这种情况,访问的是dir目录下的内容
file = "./"; //设置file的值,资源目录的当前位置
}
struct stat sbuf;
//判断文件是否存在
int ret = stat(file,&sbuf);
if(ret == -1){ //表示没有找到该文件
//回发浏览器404错误页面
send_error(cfd, 404, "Not Found", "No such file or direntry");
//printf("错误页面啊----------------------------");
return; //不能exit(1);请求文件不存在,没有必要退出服务器,可以等待对方下一个请求
}
//判断是目录还是文件
if(S_ISDIR(sbuf.st_mode)){ //是目录
//发送头信息
send_respond_head(cfd, 200, "OK", get_file_type(".html"), -1); //-1表示系统自己获取文件大小
//发送目录信息
send_dir(cfd,file);
}else if(S_ISREG(sbuf.st_mode)){ //是一个普通文件
send_respond_head(cfd,200,"OK", get_file_type(file), sbuf.st_size);
//回发给客户端请求的文件内容
send_file(cfd, file);
}
}

在得到对端请求文件的类型后,作为服务器,发送给对端内容,也需要创建一个响应头,其中下面程序就针对对端请求的文件类型,生成了对应的响应头,并先发送给对端。

1
2
3
4
5
6
7
8
9
10
11
12
13
//应答客户端协议头
void send_respond_head(int cfd, int no, const char *desp, const char *type, long len){ //客户端的fd;错误号;错误描述;回发文件类型;文>件长度
//拼接协议头
char buf[1024] = {0};
sprintf(buf, "HTTP/1.1 %d %s\r\n", no, desp);
send(cfd, buf, strlen(buf), 0);
//信息报头
sprintf(buf, "Content-Type:%s\r\n", type);
sprintf(buf+strlen(buf), "Content-Length:%ld\r\n", len);
send(cfd, buf, strlen(buf), 0); //拼接好后存在buf里面,直接通过buf发给对端
//空行
send(cfd, "\r\n", 2, 0);
}

在发送完响应头后,也知道了对端请求的是一个目录,而本地也存在该目录,接下来就是将该目录内容在满足http协议的前提下发送给对端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//发送服务器本地目录给浏览器
void send_dir(int cfd, const char* dirname){ //发送目录内容函数
int i, ret;
//拼一个html页面<table></table>
char buf[4094] = {0};

sprintf(buf, "<html><head><title>目录名:%s</title></head>", dirname); //这一行是给网页取名
sprintf(buf+strlen(buf),"<body><h1>当前目录:%s</h1><table>", dirname); //--->这一行是显示在页面上

char enstr[1024] = {0}; //后面将文件名编码后,存放的位置
char path[1024] = {0};

//目录项二级指针
struct dirent** ptr; //--->结构体里面重要的两项是inode编号,和文件名
//scandir是把dirname目录的下的目录项例出来
int num = scandir(dirname, &ptr, NULL, alphasort); //要访问的文件目录名;目录项的字符指针数组(传出);过滤器(没有用到);内容按字符排序
//得到dirname下的所有目录项存放在ptr字符数组中,num是返回该数组中的个数
//遍历
for(i=0; i<num; i++){
char* name = ptr[i]->d_name;
//拼接文件的完整路径
sprintf(path, "%s/%s", dirname, name);
printf("path = %s =================\n", path); //--->这一行输入是终端看的
struct stat st;
stat(path,&st);

//编码生成 %E5 %E6之类的东西(在浏览器搜索哪里中文和某类编码是一一对应的,回发给浏览器,要将中文转编码)
encode_str(enstr, sizeof(enstr), name); //name是目录项下的文件名(传入);enstr是传出,存放文件名编码后的unicode编码
//如果是文件
if(S_ISREG(st.st_mode)){
sprintf(buf+strlen(buf),"<tr><td><a href=\"%s\">%s</a></td><td>%ld</td></tr>", enstr, name, (long)st.st_size);
}else if(S_ISDIR(st.st_mode)){ //如果是目录
sprintf(buf+strlen(buf), "<tr><td><a href=\"%s/\">%s/</a></td><td>%ld</td></tr>", enstr, name, (long)st.st_size);
}
ret = send(cfd, buf, strlen(buf), 0); //将拼接好的内容发给对端
if(ret == -1){
if(errno == EAGAIN){
perror("send error");
continue;
}else if(errno == EINTR){
perror("send error");
continue;
}else{
perror("send error");
exit(1);
}
}
memset(buf, 0, sizeof(buf)); //清0,下一个循环继续用(一个目录下有多个子目录和子文件)
}
sprintf(buf+strlen(buf), "</table></body></html>"); //发送完对端请求目录下所有内容后,进行结尾拼接
send(cfd, buf, strlen(buf), 0); //将结尾拼接的内容发给对端
printf("dir message send OK!!!\n");
}

在发送完响应头后,也知道了对端请求的是一个具体文件,而本地也存在该文件,接下来服务器就是打开该文件读取,将文件内容在满足http协议的前提下发送给对端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//发送服务器本地文件给浏览器
void send_file(int cfd, const char *file){ //cfd是访问的客户端的socket;file是要访问的文件
int n = 0;
char buf[1024];
//打开的服务器本地文件
int fd = open(file, O_RDONLY); //以只读打开用户需要的文件
if(fd == -1){
//打开文件失败,回发404错误页面
send_error(cfd, 404, "Not Found", "No such file or direntry");
exit(1);
}
while((n = read(fd, buf, sizeof(buf))) > 0){
int ret = send(cfd, buf, n, 0); //从fd向buf写内容(用户需要的文件)
if(ret == -1){
printf("errno = %d\n",errno);
if(errno == EAGAIN){ //不算错误,直接continue
printf("----------------EAGAIN\n");
continue;
}else if(errno == EINTR){ //不算错误,直接continue
printf("---------------EINTR\n");
continue;
}else{
perror("send error");
exit(1);
}
}
if(ret < 4096){
printf("----------send ret: %d\n", ret);
}
}
close(fd);
}

2.3 辅助函数

断开连接的函数:当对端请求关闭时,会调用该函数。

1
2
3
4
5
6
7
8
9
//断开链接
void disconnect(int cfd, int epfd){ //参数:要断开的描述符;红黑树根节点
int ret = epoll_ctl(epfd,EPOLL_CTL_DEL,cfd,NULL); //将cfd从红黑树上摘下
if(ret == -1){
perror("epoll_ctl del cfd error");
exit(1);
}
close(cfd); //关闭该描述符
}

404错误页面函数:当服务器在本地没有找到对端的请求目录文件时或服务器打开本地文件出错都会调用该函数,会发送给对端一个404页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void send_error(int cfd, int status, char *title, char *text){        //错误页面404
//应答协议的协议头
char buf[4096] = {0};
sprintf(buf, "%s %d %s\r\n", "HTTP/1.1", status, title); //http协议号;错误号;错误描述
sprintf(buf+strlen(buf), "Content-Type:%s\r\n", "text/html"); //类型一定是html类型(固定的)--->因为是错误页面
sprintf(buf+strlen(buf), "Content-Length:%d\r\n", -1); //内容长度不晓得是多大就写-1
sprintf(buf+strlen(buf), "Connection: close\r\n"); //http协议是:服务器完成一次与客户端的应答,就主动断开(不写这个也可>以)
send(cfd, buf, strlen(buf), 0); //拼接好后存在buf里面,直接通过buf发给对端
send(cfd, "\r\n", 2, 0); //应答协议头的结尾标记

memset(buf, 0, sizeof(buf));
sprintf(buf, "<html><head><title>%d %s</title></head>\n", status, title);
sprintf(buf+strlen(buf), "<body bgcolor=\"#cc99cc\"><h4 align=\"center\">%d %s</h4>\n", status,title);
sprintf(buf+strlen(buf), "%s\n",text);
sprintf(buf+strlen(buf), "<hr>\n</body>\n</html>\n");
send(cfd, buf, strlen(buf), 0);
printf("-----------------------------错误页面啊----------------------------\n");
return ;
}

获取协议第一行的函数:该函数是服务器在接收到对端发来的请求时,为了得到请求头的第一行而调用的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//获取一行 http的内容结构末尾是以/r/n换行的
int get_line(int cfd, char *buf, int size){
int i=0;
char c = '\0';
int n;
while((i<size-1) && (c!='\n')){
n = recv(cfd,&c,1,0); //每次只读一个字符存到c中,有数据就读,没有数据就阻塞,读完就没有了
if(n>0){
if(c == '\r'){
n = recv(cfd,&c,1,MSG_PEEK);
if((n>0) && (c=='\n')){
recv(cfd,&c,1,0);
}else{
c='\n';
}
}
buf[i] = c;
i++;
}else{
c = '\n';
}
}
buf[i]='\0';
if(n==-1){
i=n;
}
return i;
}

编码和解码函数:编码是服务器将本地找到的文件目录(中文)转为浏览器的一些编码格式(unicode编码),发给对端;而解码就是从对端(浏览器)请求中显示的文件(unicode编格式)转为中文,这样服务器在本地也方便搜索寻找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//编码:汉字-->unicode码 ----->服务端回发给浏览器时使用
void encode_str(char* to, int tosize, const char* from){ //from是传进来的,to是编码后传出去的
int tolen;
for(tolen = 0; *from != '\0' && tolen+4 < tosize; from++){
if(isalnum(*from) || strchr("/_.-~", *from) != (char*)0){
*to = *from;
to++;
tolen++;
}else{
sprintf(to, "%%%02x",(int) *from&0xff);
to += 3;
tolen += 3;
}
}
*to = '\0';
}

//解码:unicode码-->汉字 ----->浏览器发给服务器时使用
void decode_str(char *to, char *from){
for( ; *from != '\0'; to++, from++){
if(from[0] == '%' && isxdigit(from[1]) && isxdigit(from[2])){
*to = hexit(from[1])*16 + hexit(from[2]);
from += 2;
}else{
*to = *from;
}
}
*to = '\0';
}

//16进制数转化为10进制
int hexit(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;
}

文件类型函数:通过对端请求头的请求文件,来得到该请求文件的文件类型(响应头需要文件的类型)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//通过文件名获取文件的类型
const char *get_file_type(const char *name){
char *dot;
//自右向左查找‘.’字符,如不存在返回NULL
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,".ogg")==0){
return "application/ogg";
}
if(strcmp(dot,".pac")==0){
return "application/x-ns-proxy-autoconfig";
}
return "text/plain; charset=utf-8";
}

3. 执行效果

这是服务器本地的一个文件,具体有这些文件内容:

当服务器启动后,在本地的浏览器输入服务器ip和设置的端口,就能得到如下界面:

上面页面的所有内容都可以通过在浏览器端口后面输入对应的文件名或直接点击页面文件获得,如:

点击document3_文档就可以进入如下页面,当然,还可以再点击a.txt或b.txt,访问该txt文件里面的内容

点击图片就会显示如下页面:

点击歌曲也可以播放:

对于视频,因为这个centos系统没有下载对于的视频插件,所以不能播放。