用C寫一個web服務器(一) 基礎功能


前言

C 語言是一門很基礎的語言,程序員們對它推崇備至,雖然它是我的入門語言,但大學的 C 語言知道早已經還給了老師,C 的使用可以說是從頭學起。

之前一直在讀書,看了《C Primer Plus》、《APUE》、《UNP》,第一本看完之后雖然對 C 的語法有了大概的了解,可是要說應用,還差得很遠;后兩本算是咬着牙翻完的,應用更不敢說,只是對概念有了基本的認識。

我們都知道,學一門語言,只看不寫,很容易出現眼高手低,寫代碼無處下手的情況,於是終於在下班和周末擠出時間,准備寫一個小項目。正好最近在看 nginx 服務器與 php sapi 相關的知識,於是考慮以 nginx 的思想,寫一個類似的簡化版 web 服務器。

項目最終的成果不敢保證,像上次寫的 PHP 框架,在原理通透,技術要點掌握之后只剩下功能完善和代碼堆疊,也就沒有繼續下去的欲望了,於是太監了。。。 但是跟着學習和理解一遍一定會有很大收獲,這點是能保證的。 另外一直寫同一系列的東西會讓我有一種負擔感,而且偏底層的東西也需要很多時間去學習,這一系列可能會間隔更新,歡迎關注。

最后附上項目 GitHub 地址:請點我


服務器架構

目標架構

以 nginx 的思想來考慮本服務器架構,初步考慮如下圖:

當然 php 進程也可以替換為其他的腳本語言,可以更改源碼中的 command 變量實現。

服務器有一個 master 進程,其有多個子進程為 worker 進程,master 進程受理客戶端的請求,然后分發給 worker 進程,worker 進程處理 http 頭信息后將參數傳遞給 php 進程處理后,將結果返回到上層,再響應給客戶端。

也考慮過使用 php-fpm 的 worker 進程池方式,那樣的話 php-fpm 進程也要仿寫了,目前還不熟悉其內部構造,如果可以簡單化,自然向其靠攏。目前對 PHP 的 SAPI 接口不熟,了解一下再考慮。

當前狀態

當前狀態的服務器還極其簡單,總結下來有以下地方待優化:

  • 當前還是單進程,需要改成多進程,最終為 worker 進程池方式;
  • 優化 socket IO 模型,考慮 epoll、事件驅動方式;
  • 只支持 HTTP GET 請求方法,未進行太多的異常處理來定義 http 狀態碼;
  • 與 php 進程的交互方式,考慮如 nginx 使用 unix domain socket 方式。
  • 協議目前只考慮了 http,后續會考慮一些基於 TCP 的協議;

雖然簡單,但服務器已經有基本的功能了:

它監聽本地地址的 8080 端口,將接收到的 http 頭中的 path 信息提出出來交給 php 進程,php 進程將參數信息處理后返回給服務器,服務器拼裝 http 響應信息再將結果返回給客戶端。

下面介紹各個功能的實現:


功能實現

socket系列方法

在介紹函數之間先用一張圖來介紹一次 http 請求中客戶端與服務器之間的交互:

如圖:服務器創建要進行:

  1. 調用 socket() 創建一個連接;int socket(int domain, int type, int protocol);
  2. 調用 bind() 給套接字命名,綁定端口;int bind( int socket, const struct sockaddr *address, size_t address_len);
  3. 調用 listen() 監聽此套接字;int listen(int socket, int backlog);
  4. 調用 accept() 接受客戶端的連接;int accept(int socket, struct sockaddr *address, size_t *address_len);
  5. 調用 recv() 接收客戶端的信息;int recv(int s, void *buf, int len, unsigned int flags);
  6. 調用 send() 將響應信息發送給客戶端;int send(int s, const void * msg, int len, unsigned int falgs);

socket 間的接收和發送信息在 C 中有幾個系列:write() / read() 、send() / recv() 、sendto() / recvfrom()、 sendmsg() / recvmsg(),可以自行選用。

另外函數參數釋義和要點,都被我注釋在代碼中了,感興趣的可以拉下來看一下,這些在網上也多有介紹,這里不再贅述。

服務器與 PHP cli 交互

然后是 C 進程和 php 進程的交互,考慮到簡單易用,目前在 C 進程中直接執行 php 腳本:

一開始使用 system() 函數: int system(const char *command);

system 函數會 fork 一個子進程,在子進程中以 cli 方式執行 php 腳本,並將錯誤碼或返回值返回。由於其結果類型不可控,編譯時會報一個 warning。而且它將結果返回給父進程時,還會在標准輸出中打印結果,在服務器執行時會拋出異常。

於是找到了另一個方法 popen, FILE * popen(const char * command, const char * type);

popen 同樣會 fork 一個子進程來執行 command ,然后建立管道連到子進程的標准輸出設備或標准輸入設備,然后返回一個文件指針。隨后進程便可利用此文件指針來讀取子進程的輸出設備或是寫入到子進程的標准輸入設備中。

其 type 參數便是控制連接到子進程的標准輸入還是標准輸出。我們想要子進程的標准輸出,於是傳入 type參數為 字符 “r” (read)。同理,如果想寫入子進程標准輸入的話,可以傳值 “w”(write)。

另外在接收緩沖區內容的時候也出現了一點小意外:由於使用的 fgets() 方法會以換行符\n為一段的結尾,在接收 php 進程輸出時遇到換行會結束,這里使用了一個中間字符串數組line來接收每一行的信息,將每一行的信息拼裝到結果中。

代碼如下:

char * execPHP(char *args){
        // 這里不能用變長數組,需要給command留下足夠長的空間,以存儲args參數,不然拼接參數時會棧溢出
        char command[BUFF_SIZE] = "php /Users/mfhj-dz-001-441/CLionProjects/cproject/tinyServer/index.php ";
        FILE *fp;
        static char buff[BUFF_SIZE]; // 聲明靜態變量以返回變量指針地址
        char line[BUFF_SIZE];
        strcat(command, args);
        memset(buff, 0, BUFF_SIZE); // 靜態變量會一直保留,這里初始化一下
        if((fp = popen(command, "r")) == NULL){
            strcpy(buff, "服務器內部錯誤");
        }else{
            // fgets會在獲取到換行時停止,這里將每一行拼接起來
            while (fgets(line, BUFF_SIZE, fp) != NULL){
            strcat(buff, line);
            };
        }

        return buff;
    }

報文數據處理

socket 處於應用層和傳輸層之間的虛擬層,由於設置服務器 socket 協議類型為 TCP,那么 TCP 的握手揮手、數據讀取等步驟對於我們都是透明的。我們拿到的數據即 HTTP 報文,關於 HTTP 報文結構和其字段解釋的文章非常多,這里也不再多提。

首先使用 C 的 strtok() 方法,獲取到 HTTP 頭的第一行,獲取到其 http 方法和 path 信息,將這些信息處理后,再使用 sprintf() 方法拼合 HTTP 響應報文,主要替換了 響應內容長度和響應內容。


小結

對 C 的用法還不太熟悉,沒用指針、結構等華麗操作,光簡單的實現就花了我好久。可能代碼路子也會有點野,希望有路過的大神能隨手提點一二;

服務器相關的知識很深,每一個優化點需要扎實的基礎知識來鞏固,可能我學到的也只是皮毛,文章難免有錯漏處,如果發現,煩請指出。

如果您覺得本文對您有幫助,可以點擊下面的 推薦 支持一下我。博客一直在更新,歡迎 關注


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM