用C語言製作Web伺服器
閱讀經典——《深入理解計算機系統》09
本文,我們將使用C語言從零開始實現一個支援靜態/動態網頁的Web伺服器。我們把這個伺服器叫做Tiny。
- 背景知識
- 客戶端-伺服器程式設計模型
- 使用socket處理請求與響應
- HTTP協議與靜/動態網頁
- 關鍵程式碼解析
- 實驗效果與原始碼
背景知識
Web伺服器使用HTTP協議與客戶端(即瀏覽器)通訊,而HTTP協議又基於TCP/IP協議。因此我們要做的工作就是利用Linux系統提供的TCP通訊介面來實現HTTP協議。
而Linux為我們提供了哪些網路程式設計介面呢?沒錯,就是socket(套接字),我們會在後面詳細介紹該介面的使用方式。
另外我們應該清楚Linux的系統I/O和檔案系統的關係。在Linux中,所有I/O裝置都被看作一個個檔案,I/O裝置的輸入輸出被認做讀寫檔案。網路作為一種I/O裝置,同樣被看作檔案,而且是一類特殊的檔案,即套接字檔案。
我們還要對網路通訊協議TCP/IP有一個大致的瞭解,知道IP地址和埠的作用。
接下來我們講解客戶端-伺服器程式設計模型。
客戶端-伺服器程式設計模型
客戶端-伺服器程式設計模型是一個典型的程式間通訊模型。客戶端程式和伺服器程式通常分處兩個不同的主機,如下圖所示,客戶端傳送請求給伺服器,伺服器從本地資源庫中查詢需要的資源,然後傳送響應給客戶端,最後客戶端(通常是瀏覽器)處理這個響應,把結果顯示在瀏覽器上。
這個過程看起來很簡單,但是我們需要深入具體的實現細節。我們知道,TCP是基於連線的,需要先建立連線才能互相通訊。在Linux中,socket為我們提供了方便的解決方案。
每一對網路連線稱為一個socket對,包括兩個端點的socket地址,表示如下
(cliaddr : cliport, servaddr : servport)
其中, cliaddr
和cliport
分別是客戶端IP地址和客戶端埠,servaddr
和servport
分別是伺服器IP地址和伺服器埠。舉例說明如下:
這對地址和埠唯一確定了連線的雙方,在TCP/IP協議網路中就能輕鬆地找到對方。
使用socket處理請求與響應
熟悉TCP協議的朋友們應該很容易理解下面的流程圖。
伺服器呼叫socket
函式獲取一個socket,然後呼叫bind
函式繫結本機的IP地址和埠,再呼叫listen
函式開啟監聽,最後呼叫accept
函式等待直到有客戶端發起連線。
另一方面,客戶端呼叫socket
函式獲取一個socket,然後呼叫connect
函式向指定伺服器發起連線請求,當連線成功或出現錯誤後返回。若連線成功,伺服器端的accept
函式也會成功返回,返回另一個已連線的socket(不是最初呼叫socket
函式得到的socket),該socket可以直接用於與客戶端通訊。而伺服器最初的那個socket可以繼續迴圈呼叫accept
函式,等待下一次連線的到來。
連線成功後,無論是客戶端還是伺服器,只要向socket讀寫資料就可以實現與對方socket的通訊。圖中rio_readlineb
和rio_written
是作者封裝的I/O讀寫函式,與Linux系統提供的read
和write
作用基本相同,詳細介紹見參考資料。
客戶端關閉連線時會傳送一個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
函式,該函式完成socket
、bind
、listen
等一系列操作。接著呼叫accept
函式等待客戶端請求。注意,Accept
是accept
的包裝函式,用來自動處理可能發生的異常,我們只需把它們當成一樣的就行了。當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_readlineb
和sscanf
負責讀入請求行並解析出請求方法、請求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
動態網頁效果:訪問http://localhost:8000/cgi-bin/adder?1&2
至此,我們的Web伺服器終於大功告成。大家可以下載原始碼,並在自己的計算機上部署測試。
關注作者或文集《深入理解計算機系統》,第一時間獲取最新發布文章。
參考資料
Linux IO操作詳解——RIO包 金樽對月的成長腳步
深入理解HTTP協議 micro36
CGI與Servlet的比較 YTTCJJ
我所瞭解的cgi 撣塵
Linux記憶體管理之mmap詳解 heavent2010
相關文章
- 用 C 語言編寫多程式 Web 伺服器【粗暴版】Web伺服器
- C語言練手專案--C 語言製作簡單計算器C語言
- c語言作業C語言
- C語言作業1。C語言
- C語言作業2C語言
- C語言實驗作業C語言
- 直播平臺製作,依靠C語言實現圖片輪播C語言
- 使用Go語言製作二維碼Go
- 作業系統與c語言作業系統C語言
- 週報2【C語言】【Web安全】C語言Web
- C語言I部落格作業07C語言
- C語言I部落格作業05C語言
- C語言I部落格作業04C語言
- C語言I博課作業04C語言
- C語言I部落格作業03C語言
- 純CSS製作單頁Web應用CSSWeb
- c語言實用小程式C語言
- 學會網頁製作,web app開發,你需要掌握這3個程式語言網頁WebAPP
- C語言動態陣列小作業C語言陣列
- C語言程式設計B作業04C語言程式設計
- C語言結構體作為形參C語言結構體
- C語言作業|第二次C語言
- C語言 C語言野指標C語言指標
- C語言---“C語言 誰與爭鋒?”C語言
- 組合語言---套裝軟體製作(1)(轉)組合語言
- 用c語言處理檔案C語言
- 用“世界上最好的程式語言”製作的敲詐者木馬揭秘
- c 語言實現 tcp/udp 伺服器功能TCPUDP伺服器
- C語言C語言
- 用 Go 語言 buffered channel 實作 Job QueueGo
- 用 Go 語言實作 Job Queue 機制Go
- Swift採用語言伺服器協議Swift伺服器協議
- 製作 Rust 語言非同步 ORM 框架(Mybatis)第二彈Rust非同步ORM框架MyBatis
- 製作 Rust 語言堪比 Mybatis 的非同步 ORM 框架RustMyBatis非同步ORM框架
- 聊聊C語言/C++—程式和程式語言C語言C++
- web 前端 圖示製作Web前端
- 用C語言寫strcat、strcpy、strlen、strcmpC語言
- 用C語言輸出蛇形矩陣C語言矩陣