用C語言製作Web伺服器

weixin_34321977發表於2016-05-21

閱讀經典——《深入理解計算機系統》09

本文,我們將使用C語言從零開始實現一個支援靜態/動態網頁的Web伺服器。我們把這個伺服器叫做Tiny。

  1. 背景知識
  2. 客戶端-伺服器程式設計模型
  3. 使用socket處理請求與響應
  4. HTTP協議與靜/動態網頁
  5. 關鍵程式碼解析
  6. 實驗效果與原始碼

背景知識

Web伺服器使用HTTP協議與客戶端(即瀏覽器)通訊,而HTTP協議又基於TCP/IP協議。因此我們要做的工作就是利用Linux系統提供的TCP通訊介面來實現HTTP協議。

而Linux為我們提供了哪些網路程式設計介面呢?沒錯,就是socket(套接字),我們會在後面詳細介紹該介面的使用方式。

另外我們應該清楚Linux的系統I/O和檔案系統的關係。在Linux中,所有I/O裝置都被看作一個個檔案,I/O裝置的輸入輸出被認做讀寫檔案。網路作為一種I/O裝置,同樣被看作檔案,而且是一類特殊的檔案,即套接字檔案。

我們還要對網路通訊協議TCP/IP有一個大致的瞭解,知道IP地址和埠的作用。

接下來我們講解客戶端-伺服器程式設計模型。

客戶端-伺服器程式設計模型

客戶端-伺服器程式設計模型是一個典型的程式間通訊模型。客戶端程式和伺服器程式通常分處兩個不同的主機,如下圖所示,客戶端傳送請求給伺服器,伺服器從本地資源庫中查詢需要的資源,然後傳送響應給客戶端,最後客戶端(通常是瀏覽器)處理這個響應,把結果顯示在瀏覽器上。

1186132-dd29ac148eaccd84.jpg
client-server transaction

這個過程看起來很簡單,但是我們需要深入具體的實現細節。我們知道,TCP是基於連線的,需要先建立連線才能互相通訊。在Linux中,socket為我們提供了方便的解決方案。

每一對網路連線稱為一個socket對,包括兩個端點的socket地址,表示如下

(cliaddr : cliport, servaddr : servport)

其中, cliaddrcliport分別是客戶端IP地址和客戶端埠,servaddrservport分別是伺服器IP地址和伺服器埠。舉例說明如下:

1186132-ae06657e637400ed.jpg
connection socket pair

這對地址和埠唯一確定了連線的雙方,在TCP/IP協議網路中就能輕鬆地找到對方。

使用socket處理請求與響應

熟悉TCP協議的朋友們應該很容易理解下面的流程圖。

1186132-4cab0f440489fb06.jpg
socket overview

伺服器呼叫socket函式獲取一個socket,然後呼叫bind函式繫結本機的IP地址和埠,再呼叫listen函式開啟監聽,最後呼叫accept函式等待直到有客戶端發起連線。

另一方面,客戶端呼叫socket函式獲取一個socket,然後呼叫connect函式向指定伺服器發起連線請求,當連線成功或出現錯誤後返回。若連線成功,伺服器端的accept函式也會成功返回,返回另一個已連線的socket(不是最初呼叫socket函式得到的socket),該socket可以直接用於與客戶端通訊。而伺服器最初的那個socket可以繼續迴圈呼叫accept函式,等待下一次連線的到來。

連線成功後,無論是客戶端還是伺服器,只要向socket讀寫資料就可以實現與對方socket的通訊。圖中rio_readlinebrio_written是作者封裝的I/O讀寫函式,與Linux系統提供的readwrite作用基本相同,詳細介紹見參考資料。

客戶端關閉連線時會傳送一個EOF到伺服器,伺服器讀取後關閉連線,進入下一個迴圈。

這裡面用到的所有Linux網路程式設計介面都定義在<sys/socket.h>標頭檔案中,為了更清晰地幫助大家理解每個函式的使用方法,我們列出它們的函式宣告。

#include <sys/types.h>
#include <sys/socket.h>

/**
獲取一個socket descriptor
@params:
    domain: 此處固定使用AF_INET
    type: 此處固定使用SOCK_STREAM
    protocol: 此處固定使用0
@returns:
    nonnegative descriptor if OK, -1 on error.
*/
int socket(int domain, int type, int protocol);

/**
客戶端socket向伺服器發起連線
@params:
    sockfd: 發起連線的socket descriptor
    serv_addr: 連線的目標地址和埠
    addrlen: sizeof(*serv_addr)
@returns:
    0 if OK, -1 on error
*/
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);

/**
伺服器socket繫結地址和埠
@params:
    sockfd: 當前socket descriptor
    my_addr: 指定繫結的本機地址和埠
    addrlen: sizeof(*my_addr)
@returns:
    0 if OK, -1 on error
*/
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

/**
將當前socket轉變為可以監聽外部連線請求的socket
@params:
    sockfd: 當前socket descriptor
    backlog: 請求佇列的最大長度
@returns:
    0 if OK, -1 on error
*/
int listen(int sockfd, int backlog);

/**
等待客戶端請求到達,注意,成功返回得到的是一個新的socket descriptor,
而不是輸入引數listenfd。
@params:
    listenfd: 當前正在用於監聽的socket descriptor
    addr: 客戶端請求地址(輸出引數)
    addrlen: 客戶端請求地址的長度(輸出引數)
@returns:
    成功則返回一個非負的connected descriptor,出錯則返回-1
*/
int accept(int listenfd, struct sockaddr *addr, int *addrlen);

HTTP協議與靜/動態網頁

HTTP協議的具體內容在此不再講述,不熟悉的朋友們可以檢視參考資料中的第二篇文章。

現在我們有必要說明一下所謂的靜態網頁和動態網頁。靜態網頁是指內容固定的網頁,通常是事先寫好的html文件,每次訪問得到的都是相同的內容。而動態網頁是指多次訪問可以得到不同內容的網頁,現在流行的動態網頁技術有PHP、JSP、ASP等。我們將要實現的伺服器同時支援靜態網頁和動態網頁,但動態網頁並不採用上述幾種技術實現,而是使用早期流行的CGI(Common Gateway Interface)。CGI是一種動態網頁標準,規定了外部應用程式(CGI程式)如何與Web伺服器交換資訊,但由於有許多缺點,現在幾乎已經被淘汰。關於CGI的更多資訊,可以檢視參考資料。

關鍵程式碼解析

Web伺服器主程式從main函式開始,程式碼如下。

int main(int argc, char **argv) 
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;

    /* Check command line args */
    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(1);
    }

    listenfd = Open_listenfd(argv[1]);
    while (1) {
        clientlen = sizeof(clientaddr);
        connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
        doit(connfd);
        Close(connfd);
    }
}

主函式引數需要傳入伺服器繫結的埠號碼,得到這個號碼後,呼叫Open_listenfd函式,該函式完成socketbindlisten等一系列操作。接著呼叫accept函式等待客戶端請求。注意,Acceptaccept的包裝函式,用來自動處理可能發生的異常,我們只需把它們當成一樣的就行了。當accept成功返回後,我們拿到了connected socket descriptor,然後呼叫doit函式處理請求。

doit函式定義如下。

void doit(int fd) 
{
    int is_static;
    struct stat sbuf;
    char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
    char filename[MAXLINE], cgiargs[MAXLINE];
    rio_t rio;

    /* Read request line and headers */
    Rio_readinitb(&rio, fd);
    if (!Rio_readlineb(&rio, buf, MAXLINE))
        return;
    printf("%s", buf);
    sscanf(buf, "%s %s %s", method, uri, version);
    if (strcasecmp(method, "GET")) {
        clienterror(fd, method, "501", "Not Implemented",
                    "Tiny does not implement this method");
        return;
    }
    read_requesthdrs(&rio);

    /* Parse URI from GET request */
    is_static = parse_uri(uri, filename, cgiargs);
    if (stat(filename, &sbuf) < 0) {
    clienterror(fd, filename, "404", "Not found",
            "Tiny couldn't find this file");
    return;
    }

    if (is_static) { /* Serve static content */          
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
        clienterror(fd, filename, "403", "Forbidden",
            "Tiny couldn't read the file");
        return;
    }
    serve_static(fd, filename, sbuf.st_size);
    }
    else { /* Serve dynamic content */
    if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) { 
        clienterror(fd, filename, "403", "Forbidden",
            "Tiny couldn't run the CGI program");
        return;
    }
    serve_dynamic(fd, filename, cgiargs);
    }
}

為了更接近現實,假設現在接收到的HTTP請求如下。該請求的請求頭是空的。

GET /cgi-bin/adder?15000&213 HTTP/1.0

程式碼中,Rio_readlinebsscanf負責讀入請求行並解析出請求方法、請求URI和版本號。接下來呼叫parse_uri函式,該函式利用請求uri得到訪問的檔名、CGI引數,並返回是否按照靜態網頁處理。如果是,則呼叫serve_static函式處理,否則呼叫serve_dynamic函式處理。

serve_static函式定義如下。

void serve_static(int fd, char *filename, int filesize) 
{
    int srcfd;
    char *srcp, filetype[MAXLINE], buf[MAXBUF];
 
    /* Send response headers to client */
    get_filetype(filename, filetype);
    sprintf(buf, "HTTP/1.0 200 OK\r\n");
    sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
    sprintf(buf, "%sConnection: close\r\n", buf);
    sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
    sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
    Rio_writen(fd, buf, strlen(buf));
    printf("Response headers:\n");
    printf("%s", buf);

    /* Send response body to client */
    srcfd = Open(filename, O_RDONLY, 0);
    srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
    Close(srcfd);
    Rio_writen(fd, srcp, filesize);
    Munmap(srcp, filesize);
}

直接看最後幾行程式碼。Open以只讀方式開啟請求的檔案,Mmap將該檔案直接讀取到虛擬地址空間中的任意位置,然後關閉檔案。接下來Rio_written把記憶體中的檔案寫入fd指定的connected socket descriptor,靜態頁面響應完成。Munmap刪除剛才在虛擬地址空間申請的記憶體。關於mmap函式的更多介紹見參考資料。

serve_dynamic函式定義如下。

void serve_dynamic(int fd, char *filename, char *cgiargs) 
{
    char buf[MAXLINE], *emptylist[] = { NULL };

    /* Return first part of HTTP response */
    sprintf(buf, "HTTP/1.0 200 OK\r\n"); 
    Rio_writen(fd, buf, strlen(buf));
    sprintf(buf, "Server: Tiny Web Server\r\n");
    Rio_writen(fd, buf, strlen(buf));
  
    if (Fork() == 0) { /* Child */
    /* Real server would set all CGI vars here */
    setenv("QUERY_STRING", cgiargs, 1);
    Dup2(fd, STDOUT_FILENO);         /* Redirect stdout to client */
    Execve(filename, emptylist, environ); /* Run CGI program */
    }
    Wait(NULL); /* Parent waits for and reaps child */
}

對於動態網頁請求,我們的方法是建立一個子程式,在子程式中執行CGI程式。看程式碼,Fork函式建立子程式,熟悉Linux程式的朋友們應該知道,該函式會返回兩次,一次在父程式中返回,返回值不等於0,另一次在子程式中返回,返回值為0,因此if判斷內部是子程式執行的程式碼。首先設定環境變數,用於把請求引數傳遞給CGI程式。接下來呼叫Dup2函式將標準輸出重定向到connected socket descriptor,這樣一來使用標準輸出輸出的內容將會直接傳送給客戶端。然後呼叫Execve函式在子程式中執行filename指定的CGI程式。最後在父程式中呼叫了Wait函式用於收割子程式,當子程式終止後該函式才會返回。因此該Web伺服器不能同時處理多個訪問,只能一個一個處理。

我們給出了一個CGI程式的例項adder,用於計算兩個引數之和。程式碼如下。

/*
 * adder.c - a minimal CGI program that adds two numbers together
 */
int main(void) {
    char *buf, *p;
    char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
    int n1=0, n2=0;

    /* Extract the two arguments */
    if ((buf = getenv("QUERY_STRING")) != NULL) {
    p = strchr(buf, '&');
    *p = '\0';
    strcpy(arg1, buf);
    strcpy(arg2, p+1);
    n1 = atoi(arg1);
    n2 = atoi(arg2);
    }

    /* Make the response body */
    sprintf(content, "Welcome to add.com: ");
    sprintf(content, "%sTHE Internet addition portal.\r\n<p>", content);
    sprintf(content, "%sThe answer is: %d + %d = %d\r\n<p>", 
        content, n1, n2, n1 + n2);
    sprintf(content, "%sThanks for visiting!\r\n", content);
  
    /* Generate the HTTP response */
    printf("Connection: close\r\n");
    printf("Content-length: %d\r\n", (int)strlen(content));
    printf("Content-type: text/html\r\n\r\n");
    printf("%s", content);
    fflush(stdout);

    exit(0);
}

這段程式碼就非常簡單了,從環境變數中取出請求引數,得到兩個加數的值,相加後輸出。需要注意的是,由於剛才已經重定向標準輸出,因此使用printf就可以把內容輸出給客戶端。輸出內容需要遵照HTTP協議的格式,才能在瀏覽器中正確顯示出來。

實驗效果與原始碼

輸入如下命令啟動Web伺服器,並繫結8000埠:

./tiny 8000

靜態網頁效果:訪問http://localhost:8000

1186132-8bf7410516433d2a.png
靜態網頁效果

動態網頁效果:訪問http://localhost:8000/cgi-bin/adder?1&2

1186132-70e690610b6d07f9.png
動態網頁效果

至此,我們的Web伺服器終於大功告成。大家可以下載原始碼,並在自己的計算機上部署測試。

關注作者文集《深入理解計算機系統》,第一時間獲取最新發布文章。

參考資料

Linux IO操作詳解——RIO包 金樽對月的成長腳步
深入理解HTTP協議 micro36
CGI與Servlet的比較 YTTCJJ
我所瞭解的cgi 撣塵
Linux記憶體管理之mmap詳解 heavent2010

相關文章