關於 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),該程序從標準輸入中讀數據(讀管道)或向標準輸出中寫入數據(寫管道)。