tinyhttpd是一個demo版的服務器。代碼幾百行。源碼分析在這。從中可用一窺服務器的基礎原理。他採用的是一個請求新開一個線程處理的方式。裏面涉及了多進程、多線程、進程間通信等知識。
我們從main函數開始分析。
int main(void)
{
int server_sock = -1;
u_short port = 0;
int client_sock = -1;
struct sockaddr_in client_name;
int client_name_len = sizeof(client_name);
pthread_t newthread;
// 拿到一個監聽的文件描述符
server_sock = startup(&port);
while (1)
{
// 等待連接到來
client_sock = accept(server_sock,
(struct sockaddr *)&client_name,
&client_name_len);
// 每一個連接用一個線程處理,主函數是accept_request
if (pthread_create(&newthread , NULL, accept_request, client_sock) != 0)
perror("pthread_create");
}
// 服務器退出
close(server_sock);
return(0);
}
main函數的邏輯很簡單。首先調用startup拿到一個監聽型的文件描述符。然後啓動服務器。阻塞在accept等待請求的到來。我們看看startup。
// 傳統的服務器啓動流程
int startup(u_short *port)
{
int httpd = 0;
struct sockaddr_in name;
// 拿到一個用於監聽的文件描述符
httpd = socket(PF_INET, SOCK_STREAM, 0);
memset(&name, 0, sizeof(name));
name.sin_family = AF_INET;
name.sin_port = htons(*port);
name.sin_addr.s_addr = htonl(INADDR_ANY);
// 綁定一個地址到socket,沒有的話ip是本機地址,端口隨機
if (bind(httpd, (struct sockaddr *)&name, sizeof(name)) < 0)
error_die("bind");
// port等於0,系統會隨機分配一個端口(bind函數裏實現)。這裏通過文件描述符拿到系統隨機分配的端口
if (*port == 0) /* if dynamically allocating a port */
{
int namelen = sizeof(name);
// 獲取socket綁定的地址信息
if (getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1)
error_die("getsockname");
*port = ntohs(name.sin_port);
}
if (listen(httpd, 5) < 0)
error_die("listen");
return(httpd);
}
startup函數是經典的socket編程流程。這時候服務器已經啓動。等待請求的到來。我們回憶main函數裏的accept函數。他返回的是一個和客戶端通信的文件描述符。然後新開一個線程,線程裏執行accept_request函數。把這個描述符傳給線程,讓他處理。accept_request函數的主要邏輯如下。
// 文件路徑htdocs下
sprintf(path, "htdocs%s", url);
// 最後一個字符是/說明是個目錄,則取該目錄下的index.html文件
if (path[strlen(path) - 1] == '/')
strcat(path, "index.html");
// 找不到該文件返回404
if (stat(path, &st) == -1) {
while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
not_found(client);
}
else
{
// 是個目錄
if ((st.st_mode & S_IFMT) == S_IFDIR)
strcat(path, "/index.html");
// 是可執行文件則說明是cgi程序
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
// 執行cgi程序
execute_cgi(client, path, method, query_string);
}
主要是兩個邏輯。
1 靜態文件的請求,則直接讀取文件內容,然後返回給客戶端。線程退出。
2 執行動態腳本。
下面我們只分析2。通過執行execute_cgi函數執行動態腳本。該函數比較長,分開分析。
void execute_cgi(int client, const char *path,
const char *method, const char *query_string)
{
// 一系列簡單解析http協議的邏輯
// 獲取兩個匿名管道
if (pipe(cgi_output) < 0) {
cannot_execute(client);
return;
}
if (pipe(cgi_input) < 0) {
cannot_execute(client);
return;
}
}
申請兩個管道。
// fork進程
if ( (pid = fork()) < 0 ) {
cannot_execute(client);
return;
}
創建一個進程。我們看看父進程和子進程都做了什麼事情。先看子進程
char meth_env[255];
char query_env[255];
char length_env[255];
// 先斷開文件描述符1和標準輸出file結構的關聯,然後使1指向cgi_ouput[1]指向的file結構
dup2(cgi_output[1], 1);
dup2(cgi_input[0], 0);
// 關閉讀端
close(cgi_output[0]);
// 關閉寫端
close(cgi_input[1]);
// 輸入參數給處理請求的cgi進程使用
sprintf(meth_env, "REQUEST_METHOD=%s", method);
putenv(meth_env);
if (strcasecmp(method, "GET") == 0) {
sprintf(query_env, "QUERY_STRING=%s", query_string);
putenv(query_env);
}
else { /* POST */
sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
putenv(length_env);
}
// 執行cgi進程
execl(path, path, NULL);
exit(0);
子進程輸入關閉管道的一端,然後輸入環境變量給cgi進程。然後執行cgi程序。再看父進程。
close(cgi_output[1]);
close(cgi_input[0]);
if (strcasecmp(method, "POST") == 0)
for (i = 0; i < content_length; i++) {
/*
讀數據然後寫入寫端cgi_input[1],對端是子進程的cgi_input[0],作爲子進程的標準讀入,
即子進程可以讀到這裏寫入的數據
*/
recv(client, &c, 1, 0);
write(cgi_input[1], &c, 1);
}
/*
等待子進程寫入,然後返回給客戶端,cgi_output[1]是子進程的標準輸出端,
從cgi_output[1]寫入的數據可以從cgi_output[0]讀取
*/
while (read(cgi_output[0], &c, 1) > 0)
send(client, &c, 1, 0);
// 關閉管道
close(cgi_output[0]);
close(cgi_input[1]);
// 等到子進程退出
waitpid(pid, &status, 0);
父進程同樣關閉管道一端。如果是post則把客戶端的body輸入給子進程。然後在read函數阻塞等待子進程的輸入。最後兩個進程退出。整個服務器的處理過程是,每次來一個請求(假設是cgi)。新開一個處理線程。主線程繼續監聽。然後新開的處理線程fork出一個進程執行cgi。這時候就相當於有兩個進程。父子進程互相通信完成一個客戶端的處理,然後退出。