一、前言
tinyhttpd是一个代码不到500行的超轻量型的Http Server,使用C语言开发,全部代码只有五百多行,可以通过这个这段代码理解一个Http Server的本质。
下载地址:http://sourceforge.net/projects/tinyhttpd/
按照作者在httpd.c文件中开头注释中的说法,这个是跑在Sparc Solaris 2.6上面,如果要跑在linux上面,还需要做以下修改:
1、注释掉 #include <pthread.h>
2、注释掉main函数中定义newthread的行
3、注释掉main函数中pthread_create那两行
4、在main函数中将accept_request()取消注释
5、将Makefile文件中,编译条件 -lsocket去掉
但是实际中按照上述方法测试,还是没能成功跑起来,所以我自己稍微修改了下,最终能在linux ubuntu跑起来,浏览器输入IP和端口即可查看效果。我喜欢将所需的知识点写在同一个博客中,所以部分知识点会在行文过程中直接体现。
接下来就开启剖析之旅,有问题欢迎指正!
二、目录结构
整个httpd代码文件量很少,包括README和html .cgi文件也才10个文件。
httpd.c:这个文件里面实现了WebServer服务器,所以这个文件也是整个工程的重点
simpleclient.c:这里面实现了一个普通的tcp client,没什么实际的功能
Makefile:可以直接make命令进行编译,当然linux下你可以直接输入gcc -o httpd httpd.c进行编译
index.html:这个是html文件,可以直接双击有浏览器看效果。当你服务器开启监听后,用浏览器输入服务器IP:端口后,首先收到的就是这个index.html页面。
color.cgi:这个是cgi程序,用Perl编写的,主要是接收服务器的输入,并产生一个输出发送给客户端。当你在测试页面输入颜色并点击submit后,浏览器会向服务器请求color.cgi文件,并将你输入的颜色作为参数传入color.cgi程序,然后产生一个输出到你的浏览器,你就可以看到颜色的效果了。
check.cgi:使用流程和color.cgi一致,具体功能没去深究,不过你可以将html文件里面的color.cgi换成check.cgi,然后查看效果
基础知识点:
CGI:通用网关接口(common gateway interface),描述了服务器和请求处理程序之间传输数据的一种标准。在这个httpd程序中,简单来,就是服务器收到浏览器请求cgi的请求,接下来服务器调用cgi程序,并将浏览器带过来的参数传入cgi程序执行。Perl是一个广泛被用来写CGI程序的语言,但实际上像Unix shellccript、Python、Rubby、PHP、TCL、C/C++、VB都可以用来编写CGI程序。
三、服务器程序httpd.c解析
这个文件中的函数不多,包含main函数总共才13个函数。
void accept_request(int);
void bad_request(int);
void cat(int, FILE *);
void cannot_execute(int);
void error_die(const char *);
void execute_cgi(int, const char *, const char *, const char *);
int get_line(int, char *, int);
void headers(int, const char *);
void not_found(int);
void serve_file(int, const char *);
int startup(u_short *);
void unimplemented(int);
一些函数我们只简单略过,后面会挑几个函数重点讲解。
1、accept_request:处理重套接字上监听到的一个http请求。里面包含对http包的解析过程,在这里可以体现一个服务器处理请求的流程
2、bad_request:返回给客户端这是个错误请求,错误码为400 BAD REQUEST
3、cat:读取服务器上的某个文件写到socket套接字
4、cannot_execute:主要处理发生在执行cgi程序时出现的错误
5、get_line:从socket读取一行,把回车换行等情况都统一为换行符结束
6、header:把HTTP响应的头部写到套接字中
7、not_found:客户端请求的资源不存在时的情况,即常见的404 NOT FOUND
8、server_file:调用cat把服务器文件返回给浏览器
9、startup:初始化httpd服务,包括建立套接字、绑定端口,进行监听
10、unimplemented:返回给浏览器表明收到的HTTP请求所用的method不被支持
实现流程:
1、服务器启动,在随机端口监听tcp客户端的连接
2、当收到一个http请求时(也就是一个tcp连接),先取出HTTP请求中的method(GET or POST)和url。如果带有参数,则query_string指针指向url中?后面的GET参数
3、格式化url到path数组,表示服务器请求的服务器文件路径,文件在htdocs目录下。当url以/结尾或者url是个目录,则默认在path中加上index.html,表示访问主页。
4、如果文件路径合法,对于无参数的GET请求,直接输出服务器文件到浏览器,即用HTTP格式写到套接字上面。其他情况(带参数GET、POST方式、url为可执行文件),则调用excute_cgi函数执行cgi脚本。
5、读取整个HTTP请求并丢弃,如果是POST则找出Content_Length。把HTTP 200状态码写到套接字中
6、建立两个管道,cig_input和cgi_output,并fork一个进行
7、在子进程中,把STDOUT 重定向到cgi_output的写入端,把STDIN重定向cgi_input的读取端,关闭cgi_input的写入端和cgi_ouptut的读取端,设置request_method的环境变量,Get的话设置query_string的环境变量,POST的话设置content_length的环境变量,这些环境变量都是为了给cgi脚本用的,接着用execl运行cgi程序
8、在父进程中,关闭cgi_input的读取端和cig_ouput的写入端,如果POST的话,把POST数据写到cgi_input,已被重定向到STDIN,读取cgi_output的管道输出到客户端,该管道是STDOUT。接着关闭所有管道,等待子进程结束。
关于管道这一步的数据传递,我研究了大半天才最终弄懂这个重定向的关系:
图一、管道的初始状态
匿名管道通信实际上是半双工管道,只能一方写一方读,要么父进程写子进程读,要么子进程写父进程读,如果要实现双工,那可以通过建立两个半双工管道来实现。
图二:管道的最终状态
图二为管道的最终状态,实际上应该说这是子进程中的管道的关系。因为调用fork后,每个管道在父子进程中都有文件描述符指向同一个管道。在父进程中,管道没有被重定向,还是还是原来的样子。从代码中可以看到,父进程关闭了cgi_ouput[1](写)和cgi_input[0(读){,也就是说对于cgi_output[2]这个管道来说,是用于子进程向父进程传递数据的,对于cgi_input[2]这个管道来说,是用于父进程向子进程传递数据的。因为子进程的STDOUT被重定向到cgi_output[1],也就是说子进程的输出会作为cgi_output[2]的输入,父进程在cgi_output[0]就可以读到子进程的数据,进而转发给浏览器。而由于cgi_input[0]被重定向到STDIN,所以父进程往cgi_input[1]写数据时,会通过管道传送到STDIN中,子进程可以读取STDIN从而获取父进程的数据。
在httpd代码中,就是父进程将‘color = yellow’这样的参数通过管道传递给子进程,子进程通过print函数的标准输出,将结果输出到父进程中。
这里再介绍cgi在这个httpd server中是怎么工作的:首先,fork出子进程后,初始化管道并通过putenv设置环境变量,这个环境变量在cgi程序里面可以通过getenv函数进行获取(这里只是举C语言的例子,Perl可能不叫getenv)。然后用execl函数并指定cgi路径来执行这个cgi函数,在cgi中通过管道获取父进程传递过来的请求参数。最终cgi向父进程输出完整的请求结果,也就是一段html代码,由父进返回给浏览器进行显示。
由于网上对于单个函数的注释解析已经有很多,且httpd代码实际上只有cgi处理的这一部分比较难懂,所以具体的我就不赘述了,可以参考:https://blog.csdn.net/jcjc918/article/details/42129311 实际上我这篇博客里面的图和部分解释都是从这篇博客摘过来的,感谢这位博主。
四、编译运行
首先不知道为什么,我的程序直接下载下来,修改了编译条件后,可以编译通过,但是服务端跑起来后,浏览器得到的空白页面。后面调试后发现,流程好像有点问题。
1、首先修改Makefile,将 -lsocket去掉。我用的ubuntu来编译的,我的Perl程序在/usr/bin/路径下,和htdocs中的两个cgi程序第一行的路径不一致,所以还要修改两个cgi文件的第一行为:
#!/usr/bin/perl -Tw
接下来直接编译运行:
make
./httpd
会出现:
可以看到服务端此时已经运行起来了,并且在58502端口进行监听。我的虚拟机IP为10.85.5.87,所以在浏览器输入10.85.5.87:58502 。但是此时浏览器页面出现一片空白。按照网上的说法,不需要动到程序的其他地方是可以直接在linux下运行的,但是我这里不能成功,如果有知道为什么的,麻烦告知,谢谢!
由于直接编译不成功,所以我就自己进行了调试。
1、首先在浏览器输入完之后,通过wireshark抓包,可以看到http的请求行信息,如下:
其实也可以通过代码打印看到请求行信息:
可以看到请求行方法为GET,url 为\ 版本为1.1 ,得到这些信息后我们继续分析,
在accept_request中,有额cgi标志位,用来判断是否需要执行cgi程序,可以看到当请求方式为GET 且不带参数的,是不需要执行cgi程序的,而是直接向浏览器返回默认页面。从请求行信息可以看出,确实GET方式不带参数的,所以应该直接返回默认的index.html页面才对,但实际上不是。分析代码主要出现在accept_request函数中的最后这段代码:
else
{
if ((st.st_mode & S_IFMT) == S_IFDIR)
strcat(path, "/index.html");
if ((st.st_mode & S_IXUSR) ||
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH) )
cgi = 1;
if (!cgi)
serve_file(client, path);
else
execute_cgi(client, path, method, query_string);
}
从这段代码可以看出,如果文件具有可执行权限,那么cgi被置1。大家可以通过ls -l中看出,index.html也是具有可执行权限的。这里作者的本意是否是想判断这个是否是一个可执行文件类型呢?后面我修改成如下即可在浏览器中获取正确的index.html页面:
else
{
if ((st.st_mode & S_IFMT) == S_IFDIR)
strcat(path, "/index.html");
if ((st.st_mode & S_IXUSR) ||
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH))
{
if (!cgi)
serve_file(client, path);
else
execute_cgi(client, path, method, query_string);
}
}
关于这个我也很疑惑,讲道理这代码不应该存在问题啊,求解释?
最终可以在浏览器实现输入yellow、red等颜色,背景自动改变。原程序设计只能体验一次服务端就会推出,想多次体验,要么重启服务,要么自己修改代码。
参考:
https://blog.csdn.net/jcjc918/article/details/42129311