关于 httpd 的理解和实践
之前在网上看到有人推荐新手值得学习的C语言开源项目,其中有提到 httpd,是一个超简单的 http服务器。
自己看了几遍,代码不多,就500来行,主要目的是了解一下HTTP协议以及服务器如何处理HTTP请求,下面就自己在阅读过程中遇到一些问题,整理出来形成此文。CSDN上早有大神贴出了自己的阅读心得:TinyHTTPd–超轻量型Http Server源码分析 还有 【源码剖析】tinyhttpd —— C 语言实现最简单的 HTTP 服务器 ,写的都很详细,大家感兴趣的可以去看看一看。
首先编译运行,看一下效果:
注意两点:
(1)如果直接执行 make 的话,你会遇到这个错误:cannot find -lsocket。原因是在 Linux系统中没有这个库文件,但是这个库在 linux 中有被实现,位于 libc 中,编译时被默认包含,所以可以直接在 Makefile 中去掉 -lsocket。
(2)在 htdocs 文件下,有 cgi 的程序和 html 代码,cgi 是用 perl 写的,对于POST请求,服务器会调用cgi程序进行响应,但文件中声明的 perl 执行程序位置与实际的操作系统有关,很可能与你电脑上的不一致,我这里 perl 脚本位于 /usr/local/bin 中(用 which perl 可以查看对应的路径),所以把 cgi 文件中的第一行改为:
#!/usr/local/bin/perl -Tw
使用Charles观察http报文(使用firebug时发现表单参数与请求头是分开显示的,看不到完整的HTTP原始报文)
(1)在浏览器中输入 “localhost:49418
”(注意此处的端口是随机分配的,需根据服务器端打印出的实际端口号进行设置),访问该地址,查看 http请求报文如下:
GET / HTTP/1.1
Host: 192.168.1.101:49418
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
(2)在表单中填入 green,点击”提交查询“,查看对应的HTTP请求报文如下:
POST /color.cgi HTTP/1.1
Host: localhost:49418
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:52.0) Gecko/20100101 Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://localhost:49418/
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11
color=green
注意表单参数在最后面的报文实体部分,也就是“color=green
”。
关于用perl编写的cgi脚本程序,可以参考此连接 Perl CGI编程
其中有关于环境变量的描述,正好可以对应httpd.c中设置的环境变量 QUERY_STRING
和 CONTENT_LENGTH
。
变量名 | 描述 |
---|---|
QUERY_STRING | 如果服务器与CGI程序信息的传递方式是GET,这个环境变量的值即使所传递的信息。这个信息经跟在CGI程序名的后面,两者中间用一个问号’?’分隔。 |
CONTENT_LENGTH | 如果服务器与CGI程序信息的传递方式是POST,这个环境变量即是从标准输入STDIN中可以读到的有效数据的字节数。这个环境变量在读取所输入的数据时必须使用。 |
对应于GET和POST方法,设置不同的环境变量,代码如下:
if (strcasecmp(method, "GET") == 0) {
/*设置 query_string 的环境变量*/
sprintf(query_env, "QUERY_STRING=%s", query_string);
putenv(query_env);
}
else { /* POST */
/*设置 content_length 的环境变量*/
sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
putenv(length_env);
}
execl(path, path, NULL);
exit(0);
对于execl函数,其原型为 int execl(const char *pathname, const char *arg0, .../* (char *)0 */);
简单说明一下参数:
第一个参数是文件路径(pathname),这里要着重说一下后面的参数,对execl\execlp\execle三个函数表示命令行参数的一般方法是: char *arg0, char *arg1, ..., char *argn, (char *)0
也就是说execl函数从第二个参数(arg0)开始,正好对应于命令行参数argv[0],而命令行参数arg[0]是该命令对应的字符串,并且ISO C要求argv[argc]是一个空指针,所以execl的第一个参数是文件路径(pathname),第二个参数一般就是命令本身对应的字符串,并且最后一个参数要以 (char *)0 结尾。
在APUE中的7.4节又提到,当执行一个程序时,调用exec的进程可将命令行参数传递给该新程序,进程自愿终止的唯一方法是显示或隐士地(通过调用exit)调用_exit或_Exit。(进程也可以非自愿地由一个信号使其终止)
main函数的原型是:int main(int argc, char *argv[]);
其中argc是命令行参数的数目, argv是指向参数的各个指针所构成的数组。在内核执行C程序时,先调用一个特殊的启动例程,该启动例程会从内核取得命令行参数和环境变量值,然后再执行一个exec函数。可执行程序文件将此启动例程指定为程序的起始地址(由C编译器调用,设置可执行文件的起始地址为启动例程)。
对于管道,经常看到的操作是将管道的文件描述符重定位到标准输入\输出上,例如: dup2(fd[0], STDIN_FILENO);
,将管道的读端重定位成标准输入。
if (pid == 0) /* child: CGI script */
{
char meth_env[255];
... ...
dup2(cgi_output[1], 1); //标准输出重定向到output管道的写入端 stdout:1
dup2(cgi_input[0], 0); //标准输入重定向到input管道的读取端 stdin:0
... ...
/* 调用exec函数,执行CGI脚本,通过dup2重定向,CGI的标准输出内容进入子进程管道output[1]的输入端 (在父进程中会读取管道output[0],然后将此内容发送给浏览器) */
execl(path, path, NULL);
exit(0); //子进程退出
} else { /* parent */
close(cgi_output[1]); //关闭管道的一端,这样可以建立父子进程间的管道通信
close(cgi_input[0]);
if (strcasecmp(method, "POST") == 0)
for (i = 0; i < content_length; i++) {
/* 接收 POST 过来的报文实体 (表单参数在 "报文实体" 中) */
recv(client, &c, 1, 0); //从客户端接收单个字符
/* 将报文实体内容写入input[1],由于在子进程中input[0]被重定向到了标准输入,此处写入的内容
会被CGI程序通过标准输入(input[0])读取到 */
write(cgi_input[1], &c, 1);
}
/* 读取output的管道输出到客户端,output输出端为cgi脚本执行后的内容 */
while (read(cgi_output[0], &c, 1) > 0)
send(client, &c, 1, 0); //将cgi执行结果发送给客户端,即send到浏览器
close(cgi_output[0]); //关闭剩下的管道端
close(cgi_input[1]);
waitpid(pid, &status, 0); //等待子进程终止
}
CGI请求处理部分使用了管道来做进程间通信,下面梳理一下这个处理流程。对于管道操作不熟悉的同学,可以去参考一下APUE(2e)中程序清单15-1和15-2,其中有提到一种管道的常用方法:将管道描述符复制为标准输入和标准输出,在此之后通常子进程执行另一个程序(exec),该程序从标准输入中读数据(读管道)或向标准输出中写入数据(写管道)。