親手打造 HTTP 網路服務:超小 Web Server 的撰寫

今天一時心血來潮下,前後花了 30 分鐘,動手寫了一個 Web Server,這是個僅僅使用 170 行﹝若是移除空行和註解也許只有 100 行不到﹞左右 C 語言程式所寫的『超小』Web Server,只有支援 GET 命令,沒有 CGI 、虛擬目錄的其他功能。

以下是瀏覽器對伺服器一個單純的 HTTP 通訊:

GET /index.html HTTP/1.0<CF><LF>
<CF><LF>


HTTP 協定中,標準網路通訊 Port 是 80,瀏覽器使用『GET /index.html HTTP/1.0』命令來指定抓取網站根目錄的 index.html 檔案,每行指令以換行字元『\r\n』結尾,最後再一次的『\r\n』代表等待伺服器回傳資料。就這段非常單純的通訊內容而言,我寫的 Web Server,最少要能夠解析這行命令。


撰寫 Web Server 比較需要的程式技術,大概就是 Daemon、多執行緒、網路連線操作。


關於背景服務的 Daemon 程式寫法,我在另一篇筆記上有簡單記錄,應該不是什麼大問題。

而多執行緒的使用,是為了提供多使用者同時連線,我們需要同時處理眾多用戶端的連線要求,而不是等一個用戶處理完再接受下一個人連線。但是,為了不使用太複雜的多執行緒系統機制,在這程式中我改用 fork() 多行程的方式撰寫,這樣不但簡化了程式,也剛剛好讓我的 Web Server 可以在許多嵌入式系統中執行,因為多數微型的嵌入式系統,並沒有支援多執行緒。

最後一個會用到的程式技術是網路連線操作,會和寫用戶端網路程式有極大不同,因為要監聽網路卡上的 Port,等到有用戶端連線時再進行處理。其中依照流程會呼叫下面四個函式:

socket() /* 開啟網路 Socket */
bind() /* 開啟網路監聽器 */
listen() /* 開始監聽網路 */
accept() /* 等待客戶端連線 */


這 Web Server 將會使用 /tmp 當根目錄,每次有使用者連線,就會 fork() 出一個子行程去處理用戶端的要求。主要由 main() 啟動和初始化網路監聽、fork(),然後交由自訂的 handle_socket() 去解析命令和回傳資料。

Web Server 原始碼:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define BUFSIZE 8096

struct {
char *ext;
char *filetype;
} extensions [] = {
{"gif", "image/gif" },
{"jpg", "image/jpeg"},
{"jpeg","image/jpeg"},
{"png", "image/png" },
{"zip", "image/zip" },
{"gz", "image/gz" },
{"tar", "image/tar" },
{"htm", "text/html" },
{"html","text/html" },
{"exe","text/plain" },
{0,0} };

void handle_socket(int fd)
{
int j, file_fd, buflen, len;
long i, ret;
char * fstr;
static char buffer[BUFSIZE+1];

ret = read(fd,buffer,BUFSIZE); /* 讀取瀏覽器要求 */
if (ret==0||ret==-1) {
/* 網路連線有問題,所以結束行程 */
exit(3);
}

/* 程式技巧:在讀取到的字串結尾補空字元,方便後續程式判斷結尾 */
if (ret>0&&ret<BUFSIZE)
buffer[ret] = 0;
else
buffer[0] = 0;

/* 移除換行字元 */
for (i=0;i<ret;i++)
if (buffer[i]=='\r'||buffer[i]=='\n')
buffer[i] = 0;

/* 只接受 GET 命令要求 */
if (strncmp(buffer,"GET ",4)&&strncmp(buffer,"get ",4))
exit(3);

/* 我們要把 GET /index.html HTTP/1.0 後面的 HTTP/1.0 用空字元隔開 */
for(i=4;i<BUFSIZE;i++) {
if(buffer[i] == ' ') {
buffer[i] = 0;
break;
}
}

/* 檔掉回上層目錄的路徑『..』 */
for (j=0;j<i-1;j++)
if (buffer[j]=='.'&&buffer[j+1]=='.')
exit(3);

/* 當客戶端要求根目錄時讀取 index.html */
if (!strncmp(&buffer[0],"GET /\0",6)||!strncmp(&buffer[0],"get /\0",6) )
strcpy(buffer,"GET /index.html\0");

/* 檢查客戶端所要求的檔案格式 */
buflen = strlen(buffer);
fstr = (char *)0;

for(i=0;extensions[i].ext!=0;i++) {
len = strlen(extensions[i].ext);
if(!strncmp(&buffer[buflen-len], extensions[i].ext, len)) {
fstr = extensions[i].filetype;
break;
}
}

/* 檔案格式不支援 */
if(fstr == 0) {
fstr = extensions[i-1].filetype;
}

/* 開啟檔案 */
if((file_fd=open(&buffer[5],O_RDONLY))==-1)
write(fd, "Failed to open file", 19);

/* 傳回瀏覽器成功碼 200 和內容的格式 */
sprintf(buffer,"HTTP/1.0 200 OK\r\nContent-Type: %s\r\n\r\n", fstr);
write(fd,buffer,strlen(buffer));


/* 讀取檔案內容輸出到客戶端瀏覽器 */
while ((ret=read(file_fd, buffer, BUFSIZE))>0) {
write(fd,buffer,ret);
}

exit(1);
}

main(int argc, char **argv)
{
int i, pid, listenfd, socketfd;
size_t length;
static struct sockaddr_in cli_addr;
static struct sockaddr_in serv_addr;

/* 使用 /tmp 當網站根目錄 */
if(chdir("/tmp") == -1){
printf("ERROR: Can't Change to directory %s\n",argv[2]);
exit(4);
}

/* 背景繼續執行 */
if(fork() != 0)
return 0;

/* 讓父行程不必等待子行程結束 */
signal(SIGCLD, SIG_IGN);

/* 開啟網路 Socket */
if ((listenfd=socket(AF_INET, SOCK_STREAM,0))<0)
exit(3);

/* 網路連線設定 */
serv_addr.sin_family = AF_INET;
/* 使用任何在本機的對外 IP */
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
/* 使用 80 Port */
serv_addr.sin_port = htons(80);

/* 開啟網路監聽器 */
if (bind(listenfd, (struct sockaddr *)&serv_addr,sizeof(serv_addr))<0)
exit(3);

/* 開始監聽網路 */
if (listen(listenfd,64)<0)
exit(3);

while(1) {
length = sizeof(cli_addr);
/* 等待客戶端連線 */
if ((socketfd = accept(listenfd, (struct sockaddr *)&cli_addr, &length))<0)
exit(3);

/* 分出子行程處理要求 */
if ((pid = fork()) < 0) {
exit(3);
} else {
if (pid == 0) { /* 子行程 */
close(listenfd);
handle_socket(socketfd);
} else { /* 父行程 */
close(socketfd);
}
}
}
}



後記

此 Web Server 只能在 Linux 或 Unix 上執行,不能在 MS 的環境下運作。

留言

  1. 你好厲害喔!!
    要繼續加油喔~

    回覆刪除
  2. 您過獎了!

    很多技術問題的突破,還需要大家的意見和幫助。

    回覆刪除
  3. 太強了
    這次正好有相關作業要寫
    但遇到瓶頸
    借來參考參考~感恩

    回覆刪除
  4. 想請問一下,在程式中
    buffer[BUFSIZE+1];
    但在讀的時候不是都以BUFSIZE大小去讀,+1的作法是為了什麼呢?? 謝謝

    回覆刪除
  5. 這是處理字串資料的一個習慣,在不破壞原有假設的資料長度的情況下,我們必需確保最後一個字元是 \0 ,雖然,在該程式中並未真的用到該字元,而是指定長度去讀寫它。

    但因為日後擴充功能時會用到,在設計時就先加上去了。

    回覆刪除
  6. 您好,不好意思 請教一下 為什麼 一執行程式,

    馬上就完成工作~ 完全不會背景執行是什麼原因呢??

    謝謝~

    回覆刪除
  7. 不好意思,最近剛開始學 linux 網路程式...

    所以問題比較多~

    我將您的程式 全部貼上後,

    使用 gcc -o 輸出檔名 檔名.c

    然後執行程式如下

    root@www:~/test# ./輸出檔名
    root@www:~/test#

    完全沒出現任何訊息,程式就執行結束了~

    想請教我哪個步驟有誤嗎??

    感謝您~

    回覆刪除
    回覆
    1. 這是支 Daemon,所以程式應該已經在背景執行了,你可以檢查看看。

      刪除
  8. 不好意思,請教一下,我想另外弄一個功能,

    EX.當網址輸入http://*.*.*.*/exit時,自動將程式關閉

    但目前我遇到一個問題,當此程式進入 void handle_socket(int fd) 時,有什麼方法可以強制將整隻程式關閉呢??(已有辦法判斷是否輸入http://*.*.*.*/exit)

    謝謝~

    回覆刪除
  9. 使用標準的 exit() 即可 :-)

    回覆刪除

張貼留言

這個網誌中的熱門文章

有趣的邏輯問題:是誰在說謊

Web 技術中的 Session 是什麼?

淺談 USB 通訊架構之定義(二)

淺談 USB 通訊架構之定義(一)

Reverse SSH Tunnel 反向打洞實錄