如何寫一個簡單的HTTP服務器(重做版)


最近幾天用C++重新寫了之前的HTTP服務器,對以前的代碼進行改進。新的HTTP服務器采用Reactor模式,有多個線程並且每個線程有一個EventLoop,主程序將任務分發到每個線程,其中采用的是輪盤調度來均勻分配任務。

服務器的源代碼放在Github。以前的舊版本也放在我的GitHub上,在Oh-Server倉庫中。新代碼又新建了一個倉庫。

HTTP基礎知識

寫HTTP服務器當然要了解HTTP的基礎知識。HTTP/1.1由RFC2616定義,它和TCP/IP協議族內的其他協議相同,是用於客戶和服務器之

間的通信。請求訪問資源的一端成為客戶端,而提供資源響應的一端成為服務器端。我們要寫的是服務端。

HTTP請求報文

HTTP協議規定,請求從客戶端發出,然后服務器響應該請求。一個HTTP請求報文的例子如下所示:

GET / HTTP/1.1

Host: www.cnblogs.com

User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8

Accept-Language: en-US,en;q=0.5

Connection: keep-alive

這是一個真實的HTTP請求的例子,其中每一行都以\r\n結尾。由於我們寫的是簡單的服務器,所以我們只關心其中的幾行。

第一行稱為請求行,GET是請求方法,表示獲取資源,除此之外還有POST方法、PUT方法、HEAD方法、DELETE方法和OPTIONS方法等。由於我們寫一個簡單的服務器,所以暫時僅支

GET方法。/是URI,表示客戶希望訪問的資源的URI。HTTP/1.1是HTTP協議的版本,此例中表示1.1版本。我們需要解析請求行,需要解析出方法字段、URI和HTTP協議版本。

第二行是Host字段,表示所請求的資源所在的主機名和端口號。

第三行User-Agent是客戶的瀏覽器的類型,此例是運行在Ubuntu上的Firefox瀏覽器。

第四行Accept表示客戶接受的資源的類型。

第四行Accept-Language表示客戶接受的語言類型。

第五行Connection表示服務器在發送完客戶請求的數據之后是否斷開TCP連接。keep-alive表示不斷開,close表示斷開。

HTTP應答報文

HTTP/1.1 200 OK

Server: Apache/2.2.22 (Debian)

Content-length: 1223

Content-Type: text/html

第一行為應答行,HTTP/1.1是協議版本,200是狀態碼,OK是狀態短語,表示請求正常。

第二行Server表示服務器的類型,此例中是Apache服務器。

第三行Content-length表示實體的長度,單位字節。

第四行Content-Type表示實體的文件類型。

程序運行流程

編譯完畢后在終端中輸入 ./Servant 8080開始運行服務器程序,再打開瀏覽器輸入localhost:8080訪問我們寫的HTTP服務器。

服務器在Servant.cpp中定義的mian函數中監聽到了客戶瀏覽器發送的連接請求,主函數accept客戶連接並選擇一個線程將客戶的已連接套接字注冊到此線程的EventLoop中。

EventLoop是一個事件循環,每個循環都是在試圖從其中的epoll中獲取活動的套接字描述符並交給Handler類中處理。

Handler類是處理客戶HTTP請求的類,它首先將客戶的原始請求轉發給Parser類處理,從而獲取客戶請求的解析結果,Parser類將解析后的結果存入HTTPRequest類型的結構體中。Handler類根據解析后的結果首先測試客戶請求的文件是否存在,如果不存在將返回錯誤。如果文件存在但客戶的權限不允許那么也返回客戶錯誤信息。

每個客戶請求處理完畢后就關閉此套接字然后EventLoop繼續循環。

解析HTTP請求

HTTP服務器的一個重要任務是解析HTTP請求,源代碼中Parser.hParser.cpp文件中定義的Parser類就是干這個的。為了表示解析后的結果我們定義了

一個結構體HTTPRequest結構存儲解析后的結果,定義如下:

// 解析請求后的數據存儲在HTTPRequest結構體中
typedef struct
{
    std::string method;     // 請求的方法
    std::string uri;        // 請求的uri
    std::string version;    // HTTP版本
    std::string host;       // 請求的主機名
    std::string connection; // Connection首部
} HTTPRequest;

各個字段的意義正如注釋中所示。Parser類的構造函數接受一個字符串類型的參數,解析后的值存儲在_parseResult結構體中,並提供一個接口getParseResult函數訪問解析

后的結果。解析的順序如下:

首先parseLine函數按照\r\n作為分隔解析出每一行請求,並把結果存儲在_lines中,其中每一個元素是一行請求。

然后調用parseRequestLine函數解析請求行,函數按空格解析各個字段,並把解析得到的方法、URI和HTTP版本存入HTTPRequest類型的結構體中。

最后調用parseHeaders函數解析其他頭部字段,並將結果存入HTTPRequest類型的結構體中。

線程池

要想提高服務器的性能,使用線程池是一種很好的方法,它避免了單線程的低效率,並且避免了每次創建一個線程的額外開銷。源代碼中EventLoopThreadPool.h文件定義了線程池的類EventLoopThreadPool。其中的每個線程都是一個EventLoopThreadEventLoopThreadEventLoopThread類的結合體,意思就是每個線程運行一個EventLoop。每個線程的運行函數就是EventLoop類中定義的loop函數。線程池中線程的數目由主函數設置,我設置為4。線程的數目不宜過多也不宜過少,過多的話會增加CPU的調度開銷;過少的話不能發揮多核CPU的性能。所以線程池中常駐線程的數目應該等於CPU核心數,以盡量減少任務切換帶來的額外開銷並充分發揮處理器的性能。

具體實現請參考源代碼。

其他模塊的作用

由於我們寫的是非阻塞的HTTP服務器,所以緩沖區是必須要有的。在讀取客戶瀏覽器的請求和發送服務器的響應時我們會先將不完整的請求和響應暫存到緩沖區中,等到數據全部讀完或者寫完后再一並發送或者交給其他模塊處理。所以Buffer.cppBuffer.h文件中定義的Buffer類就顯得非常有用了。

Buffer類有readFdsendFd函數分別用於讀取客戶的請求和發送服務器的響應。Buffer類底層存儲的是一個char型的vector,每次添加數據就調用push_back將數據添加到字符數組末尾。_readIndex_writeIndex分別表示開始讀的索引和開始寫的索引,用這兩個索引可以方便的讀和寫數據。

I/O復用

我們使用I/O復用技術的epoll系列函數來監聽套接字並通知主函數。至於為什么選擇epoll而不是select或者poll,是因為epoll采用回調的方式通知事件;而selectpoll采用的

都是輪詢的方式,每次調用都要掃描整個注冊文件描述符集合,並將其中就緒的描述符返回給用戶。因此它們的時間復雜度是O(n),而epoll由於采用回調的方式所以其時間復雜度為O(1)。

但是當活動連接比較多的時候epoll_wait的效率未必比selectpoll高多少,因為此時回調函數觸發的比較頻繁,所以epoll_wait用於連接數量多但是活動連接比較少的情況。

其他注意的地方

  1. 對於監聽套接字設置SO_REUSEADDR套接字選項,以允許服務器在其派生的子進程正在處理客戶請求的過程中重啟服務器進程。

  2. 忽略SIGPIPE信號,當一個進程向某個已收到RST的套接字執行寫操作時,內核向該進程發送一個SIGPIPE信號,該信號的默認動作是終止進程,因此進程必須捕獲它以免被終止。

參考資料:

UNIX網絡編程(第三版)卷一 人民郵電出版社

深入理解計算機系統(第二版) 機械工業出版社

Linux多線程服務端編程 電子工業出版社

Linux高性能服務器編程 機械工業出版社


免責聲明!

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



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