項目介紹
本項目實現(xiàn)的是一個HTTP服務器,項目中將會通過基本的網(wǎng)絡套接字讀取客戶端發(fā)來的HTTP請求并進行分析,最終構(gòu)建HTTP響應并返回給客戶端。
HTTP在網(wǎng)絡應用層中的地位是不可撼動的,無論是移動端還是PC端瀏覽器,HTTP無疑是打開互聯(lián)網(wǎng)應用窗口的重要協(xié)議。
該項目將會把HTTP中最核心的模塊抽取出來,采用CS模型實現(xiàn)一個小型的HTTP服務器,目的在于理解HTTP協(xié)議的處理過程。
該項目主要涉及C/C++、HTTP協(xié)議、網(wǎng)絡套接字編程、CGI、單例模式、多線程、線程池等方面的技術(shù)。
網(wǎng)絡協(xié)議棧介紹
協(xié)議分層
協(xié)議分層
網(wǎng)絡協(xié)議棧的分層情況如下:

網(wǎng)絡協(xié)議棧中各層的功能如下:
- 應用層:根據(jù)特定的通信目的,對數(shù)據(jù)進行分析處理,以達到某種業(yè)務性的目的。
- 傳輸層:處理傳輸時遇到的問題,主要是保證數(shù)據(jù)傳輸?shù)目煽啃浴?/li>
- 網(wǎng)絡層:完成數(shù)據(jù)的轉(zhuǎn)發(fā),解決數(shù)據(jù)去哪里的問題。
- 鏈路層:負責數(shù)據(jù)真正的發(fā)生過程。
數(shù)據(jù)的封裝與分用
數(shù)據(jù)的封裝與分用
數(shù)據(jù)封裝與分用的過程如下:

也就是說,發(fā)送端在發(fā)生數(shù)據(jù)前,該數(shù)據(jù)需要先自頂向下貫穿網(wǎng)絡協(xié)議棧完成數(shù)據(jù)的封裝,在這個過程中,每一層協(xié)議都會為該數(shù)據(jù)添加上對應的報頭信息。接收端在收到數(shù)據(jù)后,該數(shù)據(jù)需要先自底向上貫穿網(wǎng)絡協(xié)議棧完成數(shù)據(jù)的解包和分用,在這個過程中,每一層協(xié)議都會將對應的報頭信息提取出來。
而本項目要做的就是,在接收到客戶端發(fā)來的HTTP請求后,將HTTP的報頭信息提取出來,然后對數(shù)據(jù)進行分析處理,最終將處理結(jié)果添加上HTTP報頭再發(fā)送給客戶端。
需要注意的是,該項目中我們所處的位置是應用層,因此我們讀取的HTTP請求實際是從傳輸層讀取上來的,而我們發(fā)送的HTTP響應實際也只是交給了傳輸層,數(shù)據(jù)真正的發(fā)送還得靠網(wǎng)絡協(xié)議棧中的下三層來完成,這里直接說“接收到客戶端的HTTP請求”以及“發(fā)送HTTP響應給客戶端”,只是為了方便大家理解,此外,同層協(xié)議之間本身也是可以理解成是在直接通信的。
HTTP相關(guān)知識介紹
HTTP的特點
HTTP的五大特點
HTTP的五大特點如下:
- 客戶端服務器模式(CS,BS): 在一條通信線路上必定有一端是客戶端,另一端是服務器端,請求從客戶端發(fā)出,服務器響應請求并返回。
- 簡單快速: 客戶端向服務器請求服務時,只需傳送請求方法和請求資源路徑,不需要發(fā)送額外過多的數(shù)據(jù),并且由于HTTP協(xié)議結(jié)構(gòu)較為簡單,使得HTTP服務器的程序規(guī)模小,因此通信速度很快。
- 靈活: HTTP協(xié)議對數(shù)據(jù)對象沒有要求,允許傳輸任意類型的數(shù)據(jù)對象,對于正在傳輸?shù)臄?shù)據(jù)類型,HTTP協(xié)議將通過報頭中的Content-Type屬性加以標記。
- 無連接: 每次連接都只會對一個請求進行處理,當服務器對客戶端的請求處理完畢并收到客戶端的應答后,就會直接斷開連接。HTTP協(xié)議采用這種方式可以大大節(jié)省傳輸時間,提高傳輸效率。
- 無狀態(tài): HTTP協(xié)議自身不對請求和響應之間的通信狀態(tài)進行保存,每個請求都是獨立的,這是為了讓HTTP能更快地處理大量事務,確保協(xié)議的可伸縮性而特意設計的。
說明一下:
- 隨著HTTP的普及,文檔中包含大量圖片的情況多了起來,每次請求都要斷開連接,無疑增加了通信量的開銷,因此HTTP1.1支持了長連接Keey-Alive,就是任意一端只要沒有明確提出斷開連接,則保持連接狀態(tài)。(當前項目實現(xiàn)的是1.0版本的HTTP服務器,因此不涉及長連接)
- HTTP無狀態(tài)的特點無疑可以減少服務器內(nèi)存資源的消耗,但是問題也是顯而易見的。比如某個網(wǎng)站需要登錄后才能訪問,由于無狀態(tài)的特點,那么每次跳轉(zhuǎn)頁面的時候都需要重新登錄。為了解決無狀態(tài)的問題,于是引入了Cookie技術(shù),通過在請求和響應報文中寫入Cookie信息來控制客戶端的狀態(tài),同時為了保護用戶數(shù)據(jù)的安全,又引入了Session技術(shù),因此現(xiàn)在主流的HTTP服務器都是通過Cookie+Session的方式來控制客戶端的狀態(tài)的。
URL格式
URL(Uniform Resource Lacator)叫做統(tǒng)一資源定位符,也就是我們通常所說的網(wǎng)址,是因特網(wǎng)的萬維網(wǎng)服務程序上用于指定信息位置的表示方法。
一個URL大致由如下幾部分構(gòu)成:

簡單說明:
- http://表示的是協(xié)議名稱,表示請求時需要使用的協(xié)議,通常使用的是HTTP協(xié)議或安全協(xié)議HTTPS。
- user:pass表示的是登錄認證信息,包括登錄用戶的用戶名和密碼。(可省略)
- www.example.jp表示的是服務器地址,通常以域名的形式表示。
- 80表示的是服務器的端口號。(可省略)
- /dir/index.html表示的是要訪問的資源所在的路徑(/表示的是web根目錄)。
- uid=1表示的是請求時通過URL傳遞的參數(shù),這些參數(shù)以鍵值對的形式通過&符號分隔開。(可省略)
- ch1表示的是片段標識符,是對資源的部分補充。(可省略)
注意:
- 如果訪問服務器時沒有指定要訪問的資源路徑,那么瀏覽器會自動幫我們添加/,但此時仍然沒有指明要訪問web根目錄下的哪一個資源文件,這時默認訪問的是目標服務的首頁。
- 大部分URL中的端口號都是省略的,因為常見協(xié)議對應的端口號都是固定的,比如HTTP、HTTPS和SSH對應的端口號分別是80、443和22,在使用這些常見協(xié)議時不必指明協(xié)議對應的端口號,瀏覽器會自動幫我們進行填充。
URI、URL、URN
URI、URL、URN的定義
URI、URL、URN的定義如下:
- URI(Uniform Resource Indentifier)統(tǒng)一資源標識符:用來唯一標識資源。
- URL(Uniform Resource Locator)統(tǒng)一資源定位符:用來定位唯一的資源。
- URN(Uniform Resource Name)統(tǒng)一資源名稱:通過名字來標識資源,比如mailto:java-net@java.sun.com。
URI、URL、URN三者的關(guān)系
URL是URI的一種,URL不僅能唯一標識資源,還定義了該如何訪問或定位該資源,URN也是URI的一種,URN通過名字來標識資源,因此URL和URN都是URI的子集。
URI、URL、URN三者的關(guān)系如下:

絕對的URI和相對的URI
URI有絕對和相對之分:
- 絕對的URI: 對標識符出現(xiàn)的環(huán)境沒有依賴,比如URL就是一種絕對的URI,同一個URL無論出現(xiàn)在什么地方都能唯一標識同一個資源。
- 相對的URI: 對標識符出現(xiàn)的環(huán)境有依賴,比如HTTP請求行中的請求資源路徑就是一種相對的URI,這個資源路徑出現(xiàn)在不同的主機上標識的就是不同的資源。
HTTP的協(xié)議格式
HTTP請求協(xié)議格式
HTTP請求協(xié)議格式如下:

HTTP請求由以下四部分組成:
- 請求行:[請求方法] + [URI] + [HTTP版本]。
- 請求報頭:請求的屬性,這些屬性都是以key: value的形式按行陳列的。
- 空行:遇到空行表示請求報頭結(jié)束。
- 請求正文:請求正文允許為空字符串,如果請求正文存在,則在請求報頭中會有一個Content-Length屬性來標識請求正文的長度。
HTTP響應協(xié)議格式
HTTP響應協(xié)議格式如下:

HTTP響應由以下四部分組成:
- 狀態(tài)行:[HTTP版本] + [狀態(tài)碼] + [狀態(tài)碼描述]。
- 響應報頭:響應的屬性,這些屬性都是以key: value的形式按行陳列的。
- 空行:遇到空行表示響應報頭結(jié)束。
- 響應正文:響應正文允許為空字符串,如果響應正文存在,則在響應報頭中會有一個Content-Length屬性來標識響應正文的長度。
HTTP的請求方法
HTTP的請求方法
HTTP常見的請求方法如下:

GET方法和POST方法
HTTP的請求方法中最常用的就是GET方法和POST方法,其中GET方法一般用于獲取某種資源信息,而POST方法一般用于將數(shù)據(jù)上傳給服務器,但實際GET方法也可以用來上傳數(shù)據(jù),比如百度搜索框中的數(shù)據(jù)就是使用GET方法提交的。
GET方法和POST方法都可以帶參,其中GET方法通過URL傳參,POST方法通過請求正文傳參。由于URL的長度是有限制的,因此GET方法攜帶的參數(shù)不能太長,而POST方法通過請求正文傳參,一般參數(shù)長度沒有限制。
HTTP的狀態(tài)碼
HTTP的狀態(tài)碼
HTTP狀態(tài)碼是用來表示服務器HTTP響應狀態(tài)的3位數(shù)字代碼,通過狀態(tài)碼可以知道服務器端是否正確的處理了請求,以及請求處理錯誤的原因。
HTTP的狀態(tài)碼如下:

常見狀態(tài)碼
常見的狀態(tài)碼如下:

HTTP常見的Header
HTTP常見的Header
HTTP常見的Header如下:
- Content-Type:數(shù)據(jù)類型(text/html等)。
- Content-Length:正文的長度。
- Host:客戶端告知服務器,所請求的資源是在哪個主機的哪個端口上。
- User-Agent:聲明用戶的操作系統(tǒng)和瀏覽器的版本信息。
- Referer:當前頁面是哪個頁面跳轉(zhuǎn)過來的。
- Location:搭配3XX狀態(tài)碼使用,告訴客戶端接下來要去哪里訪問。
- Cookie:用戶在客戶端存儲少量信息,通常用于實現(xiàn)會話(session)的功能。
簡歷沒項目可寫?加入學習更多實戰(zhàn)項目(完整視頻教程+源碼+難點答疑)

CGI機制介紹
CGI機制的概念
CGI(Common Gateway Interface,通用網(wǎng)關(guān)接口)是一種重要的互聯(lián)網(wǎng)技術(shù),可以讓一個客戶端,從網(wǎng)頁瀏覽器向執(zhí)行在網(wǎng)絡服務器上的程序請求數(shù)據(jù)。CGI描述了服務器和請求處理程序之間傳輸數(shù)據(jù)的一種標準。
實際我們在進行網(wǎng)絡請求時,無非就兩種情況:
- 瀏覽器想從服務器上拿下來某種資源,比如打開網(wǎng)頁、下載等。
- 瀏覽器想將自己的數(shù)據(jù)上傳至服務器,比如上傳視頻、登錄、注冊等。

通常從服務器上獲取資源對應的請求方法就是GET方法,而將數(shù)據(jù)上傳至服務器對應的請求方法就是POST方法,但實際GET方法有時也會用于上傳數(shù)據(jù),只不過POST方法是通過請求正文傳參的,而GET方法是通過URL傳參的。
而用戶將自己的數(shù)據(jù)上傳至服務器并不僅僅是為了上傳,用戶上傳數(shù)據(jù)的目的是為了讓HTTP或相關(guān)程序?qū)υ摂?shù)據(jù)進行處理,比如用戶提交的是搜索關(guān)鍵字,那么服務器就需要在后端進行搜索,然后將搜索結(jié)果返回給瀏覽器,再由瀏覽器對HTML文件進行渲染刷新展示給用戶。
但實際對數(shù)據(jù)的處理與HTTP的關(guān)系并不大,而是取決于上層具體的業(yè)務場景的,因此HTTP不對這些數(shù)據(jù)做處理。但HTTP提供了CGI機制,上層可以在服務器中部署若干個CGI程序,這些CGI程序可以用任何程序設計語言編寫,當HTTP獲取到數(shù)據(jù)后會將其提交給對應CGI程序進行處理,然后再用CGI程序的處理結(jié)果構(gòu)建HTTP響應返回給瀏覽器。

其中HTTP獲取到數(shù)據(jù)后,如何調(diào)用目標CGI程序、如何傳遞數(shù)據(jù)給CGI程序、如何拿到CGI程序的處理結(jié)果,這些都屬于CGI機制的通信細節(jié),而本項目就是要實現(xiàn)一個HTTP服務器,因此CGI的所有交互細節(jié)都需要由我們來完成。
何時需要使用CGI模式
只要用戶請求服務器時上傳了數(shù)據(jù),那么服務器就需要使用CGI模式對用戶上傳的數(shù)據(jù)進行處理,而如果用戶只是單純的想請求服務器上的某個資源文件則不需要使用CGI模式,此時直接將用戶請求的資源文件返回給用戶即可。
此外,如果用戶請求的是服務器上的一個可執(zhí)行程序,說明用戶想讓服務器運行這個可執(zhí)行程序,此時也需要使用CGI模式。
CGI機制的實現(xiàn)步驟
一、創(chuàng)建子進程進行程序替換
服務器獲取到新連接后一般會創(chuàng)建一個新線程為其提供服務,而要執(zhí)行CGI程序一定需要調(diào)用exec系列函數(shù)進行進程程序替換,但服務器創(chuàng)建的新線程與服務器進程使用的是同一個進程地址空間,如果直接讓新線程調(diào)用exec系列函數(shù)進行進程程序替換,此時服務器進程的代碼和數(shù)據(jù)就會直接被替換掉,相當于HTTP服務器在執(zhí)行一次CGI程序后就直接退出了,這肯定是不合理的。因此新線程需要先調(diào)用fork函數(shù)創(chuàng)建子進程,然后讓子進程調(diào)用exec系列函數(shù)進行進程程序替換。
二、完成管道通信信道的建立
調(diào)用CGI程序的目的是為了讓其進行數(shù)據(jù)處理,因此我們需要通過某種方式將數(shù)據(jù)交給CGI程序,并且還要能夠獲取到CGI程序處理數(shù)據(jù)后的結(jié)果,也就是需要進行進程間通信。因為這里的服務器進程和CGI進程是父子進程,因此優(yōu)先選擇使用匿名管道。
由于父進程不僅需要將數(shù)據(jù)交給子進程,還需要從子進程那里獲取數(shù)據(jù)處理的結(jié)果,而管道是半雙工通信的,為了實現(xiàn)雙向通信于是需要借助兩個匿名管道,因此在創(chuàng)建調(diào)用fork子進程之前需要先創(chuàng)建兩個匿名管道,在創(chuàng)建子進程后還需要父子進程分別關(guān)閉兩個管道對應的讀寫端。
三、完成重定向相關(guān)的設置
創(chuàng)建用于父子進程間通信的兩個匿名管道時,父子進程都是各自用兩個變量來記錄管道對應讀寫端的文件描述符的,但是對于子進程來說,當子進程調(diào)用exec系列函數(shù)進行程序替換后,子進程的代碼和數(shù)據(jù)就被替換成了目標CGI程序的代碼和數(shù)據(jù),這也就意味著被替換后的CGI程序無法得知管道對應的讀寫端,這樣父子進程之間也就無法進行通信了。
需要注意的是,進程程序替換只替換對應進程的代碼和數(shù)據(jù),而對于進程的進程控制塊、頁表、打開的文件等內(nèi)核數(shù)據(jù)結(jié)構(gòu)是不做任何替換的。因此子進程進行進程程序替換后,底層創(chuàng)建的兩個匿名管道仍然存在,只不過被替換后的CGI程序不知道這兩個管道對應的文件描述符罷了。
這時我們可以做一個約定:被替換后的CGI程序,從標準輸入讀取數(shù)據(jù)等價于從管道讀取數(shù)據(jù),向標準輸出寫入數(shù)據(jù)等價于向管道寫入數(shù)據(jù)。這樣一來,所有的CGI程序都不需要得知管道對應的文件描述符了,當需要讀取數(shù)據(jù)時直接從標準輸入中進行讀取,而數(shù)據(jù)處理的結(jié)果就直接寫入標準輸出就行了。
當然,這個約定并不是你說有就有的,要實現(xiàn)這個約定需要在子進程被替換之前進行重定向,將0號文件描述符重定向到對應管道的讀端,將1號文件描述符重定向到對應管道的寫端。
四、父子進程交付數(shù)據(jù)
這時父子進程已經(jīng)能夠通過兩個匿名管道進行通信了,接下來就應該討論父進程如何將數(shù)據(jù)交給CGI程序,以及CGI程序如何將數(shù)據(jù)處理結(jié)果交給父進程了。
父進程將數(shù)據(jù)交給CGI程序:
- 如果請求方法為GET方法,那么用戶是通過URL傳遞參數(shù)的,此時可以在子進程進行進程程序替換之前,通過putenv函數(shù)將參數(shù)導入環(huán)境變量,由于環(huán)境變量也不受進程程序替換的影響,因此被替換后的CGI程序就可以通過getenv函數(shù)來獲取對應的參數(shù)。
- 如果請求方法為POST方法,那么用戶是通過請求正文傳參的,此時父進程直接將請求正文中的數(shù)據(jù)寫入管道傳遞給CGI程序即可,但是為了讓CGI程序知道應該從管道讀取多少個參數(shù),父進程還需要通過putenv函數(shù)將請求正文的長度導入環(huán)境變量。
說明一下:請求正文長度、URL傳遞的參數(shù)以及請求方法都比較短,通過寫入管道來傳遞會導致效率降低,因此選擇通過導入環(huán)境變量的方式來傳遞。
也就是說,使用CGI模式時如果請求方法為POST方法,那么CGI程序需要從管道讀取父進程傳遞過來的數(shù)據(jù),如果請求方法為GET方法,那么CGI程序需要從環(huán)境變量中獲取父進程傳遞過來的數(shù)據(jù)。
但被替換后的CGI程序?qū)嶋H并不知道本次HTTP請求所對應的請求方法,因此在子進程在進行進程程序替換之前,還需要通過putenv函數(shù)將本次HTTP請求所對應的請求方法也導入環(huán)境變量。因此CGI程序啟動后,首先需要先通過環(huán)境變量得知本次HTTP請求所對應的請求方法,然后再根據(jù)請求方法對應從管道或環(huán)境變量中獲取父進程傳遞過來的數(shù)據(jù)。
CGI程序讀取到父進程傳遞過來的數(shù)據(jù)后,就可以進行對應的數(shù)據(jù)處理了,最終將數(shù)據(jù)處理結(jié)果寫入到管道中,此時父進程就可以從管道中讀取CGI程序的處理結(jié)果了。
CGI機制的意義
CGI機制的處理流程
CGI機制的處理流程如下:

處理HTTP請求的步驟如下:
- 判斷請求方法是GET方法還是POST方法,如果是GET方法帶參或POST方法則進行CGI處理,如果是GET方法不帶參則進行非CGI處理。
- 非CGI處理就是直接根據(jù)用戶請求的資源構(gòu)建HTTP響應返回給瀏覽器。
- CGI處理就是通過創(chuàng)建子進程進行程序替換的方式來調(diào)用CGI程序,通過創(chuàng)建匿名管道、重定向、導入環(huán)境變量的方式來與CGI程序進行數(shù)據(jù)通信,最終根據(jù)CGI程序的處理結(jié)果構(gòu)建HTTP響應返回給瀏覽器。
CGI機制的意義
- CGI機制就是讓服務器將獲取到的數(shù)據(jù)交給對應的CGI程序進行處理,然后將CGI程序的處理結(jié)果返回給客戶端,這顯然讓服務器邏輯和業(yè)務邏輯進行了解耦,讓服務器和業(yè)務程序可以各司其職。
- CGI機制使得瀏覽器輸入的數(shù)據(jù)最終交給了CGI程序,而CGI程序輸出的結(jié)果最終交給了瀏覽器。這也就意味著CGI程序的開發(fā)者,可以完全忽略中間服務器的處理邏輯,相當于CGI程序從標準輸入就能讀取到瀏覽器輸入的內(nèi)容,CGI程序?qū)懭霕藴瘦敵龅臄?shù)據(jù)最終就能輸出到瀏覽器。
日志編寫
服務器在運作時會產(chǎn)生一些日志,這些日志會記錄下服務器運行過程中產(chǎn)生的一些事件。
日志格式
本項目中的日志格式如下:

日志說明:
- 日志級別: 分為四個等級,從低到高依次是INFO、WARNING、ERROR、FATAL。
- 時間戳: 事件產(chǎn)生的時間。
- 日志信息: 事件產(chǎn)生的日志信息。
- 錯誤文件名稱: 事件在哪一個文件產(chǎn)生。
- 行數(shù): 事件在對應文件的哪一行產(chǎn)生。
日志級別說明:
- INFO: 表示正常的日志輸出,一切按預期運行。
- WARNING: 表示警告,該事件不影響服務器運行,但存在風險。
- ERROR: 表示發(fā)生了某種錯誤,但該事件不影響服務器繼續(xù)運行。
- FATAL: 表示發(fā)生了致命的錯誤,該事件將導致服務器停止運行。
日志函數(shù)編寫
我們可以針對日志編寫一個輸出日志的Log函數(shù),該函數(shù)的參數(shù)就包括日志級別、日志信息、錯誤文件名稱、錯誤的行數(shù)。如下:
{
std::cout<<"["<}<<"]["<
說明一下: 調(diào)用time函數(shù)時傳入nullptr即可獲取當前的時間戳,因此調(diào)用Log函數(shù)時不必傳入時間戳。
文件名稱和行數(shù)的問題
通過C語言中的預定義符號__FILE__和__LINE__,分別可以獲取當前文件的名稱和當前的行數(shù),但最好在調(diào)用Log函數(shù)時不用調(diào)用者顯示的傳入__FILE__和__LINE__,因為每次調(diào)用Log函數(shù)時傳入的這兩個參數(shù)都是固定的。
需要注意的是,不能將__FILE__和__LINE__設置為參數(shù)的缺省值,因為這樣每次獲取到的都是Log函數(shù)所在的文件名稱和所在的行數(shù)。而宏可以在預處理期間將代碼插入到目標地點,因此我們可以定義如下宏:
后續(xù)需要打印日志的時候就直接調(diào)用LOG,調(diào)用時只需要傳入日志級別和日志信息,在預處理期間__FILE__和__LINE__就會被插入到目標地點,這時就能獲取到日志產(chǎn)生的文件名稱和對應的行數(shù)了。
日志級別傳入問題
我們后續(xù)調(diào)用LOG傳入日志級別時,肯定希望以INFO、WARNING這樣的方式傳入,而不是以"INFO"、"WARNING"這樣的形式傳入,這時我們可以將這四個日志級別定義為宏,然后通過#將宏參數(shù)level變成對應的字符串。如下:
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
此時以INFO、WARNING的方式傳入LOG的宏參數(shù),就會被轉(zhuǎn)換成對應的字符串傳遞給Log函數(shù)的level參數(shù),后續(xù)我們就可以以如下方式輸出日志了:
套接字相關(guān)代碼編寫
套接字相關(guān)代碼編寫
我們可以將套接字相關(guān)的代碼封裝到TcpServer類中,在初始化TcpServer對象時完成套接字的創(chuàng)建、綁定和監(jiān)聽動作,并向外提供一個Sock接口用于獲取監(jiān)聽套接字。
此外,可以將TcpServer設置成單例模式:
- 將TcpServer類的構(gòu)造函數(shù)設置為私有,并將拷貝構(gòu)造和拷貝賦值函數(shù)設置為私有或刪除,防止外部創(chuàng)建或拷貝對象。
- 提供一個指向單例對象的static指針,并在類外將其初始化為nullptr。
- 提供一個全局訪問點獲取單例對象,在單例對象第一次被獲取的時候就創(chuàng)建這個單例對象并進行初始化。
代碼如下:
//TCP服務器
class TcpServer{
private:
int _port; //端口號
int _listen_sock; //監(jiān)聽套接字
static TcpServer* _svr; //指向單例對象的static指針
private:
//構(gòu)造函數(shù)私有
TcpServer(int port)
:_port(port)
,_listen_sock(-1)
{}
//將拷貝構(gòu)造函數(shù)和拷貝賦值函數(shù)私有或刪除(防拷貝)
TcpServer(const TcpServer&)=delete;
TcpServer* operator=(const TcpServer&)=delete;
public:
//獲取單例對象
static TcpServer* GetInstance(int port)
{
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定義靜態(tài)的互斥鎖
if(_svr == nullptr){
pthread_mutex_lock(&mtx); //加鎖
if(_svr == nullptr){
//創(chuàng)建單例TCP服務器對象并初始化
_svr = new TcpServer(port);
_svr->InitServer();
}
pthread_mutex_unlock(&mtx); //解鎖
}
return _svr; //返回單例對象
}
//初始化服務器
void InitServer()
{
Socket(); //創(chuàng)建套接字
Bind(); //綁定
Listen(); //監(jiān)聽
LOG(INFO, "tcp_server init ... success");
}
//創(chuàng)建套接字
void Socket()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_listen_sock < 0){ //創(chuàng)建套接字失敗
LOG(FATAL, "socket error!");
exit(1);
}
//設置端口復用
int opt = 1;
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
LOG(INFO, "create socket ... success");
}
//綁定
void Bind()
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){ //綁定失敗
LOG(FATAL, "bind error!");
exit(2);
}
LOG(INFO, "bind socket ... success");
}
//監(jiān)聽
void Listen()
{
if(listen(_listen_sock, BACKLOG) < 0){ //監(jiān)聽失敗
LOG(FATAL, "listen error!");
exit(3);
}
LOG(INFO, "listen socket ... success");
}
//獲取監(jiān)聽套接字
int Sock()
{
return _listen_sock;
}
~TcpServer()
{
if(_listen_sock >= 0){ //關(guān)閉監(jiān)聽套接字
close(_listen_sock);
}
}
};
//單例對象指針初始化為nullptr
TcpServer* TcpServer::_svr = nullptr;
說明一下:
- 如果使用的是云服務器,那么在設置服務器的IP地址時,不需要顯式綁定IP地址,直接將IP地址設置為INADDR_ANY即可,此時服務器就可以從本地任何一張網(wǎng)卡當中讀取數(shù)據(jù)。此外,由于INADDR_ANY本質(zhì)就是0,因此在設置時不需要進行網(wǎng)絡字節(jié)序列的轉(zhuǎn)換。
- 在第一次調(diào)用GetInstance獲取單例對象時需要創(chuàng)建單例對象,這時需要定義一個鎖來保證線程安全,代碼中以PTHREAD_MUTEX_INITIALIZER的方式定義的靜態(tài)的鎖是不需要釋放的,同時為了保證后續(xù)調(diào)用GetInstance獲取單例對象時不會頻繁的加鎖解鎖,因此代碼中以雙檢查的方式進行加鎖。
HTTP服務器主體邏輯
HTTP服務器主體邏輯
我們可以將HTTP服務器封裝成一個HttpServer類,在構(gòu)造HttpServer對象時傳入一個端口號,之后就可以調(diào)用Loop讓服務器運行起來了。服務器運行起來后要做的就是,先獲取單例對象TcpServer中的監(jiān)聽套接字,然后不斷從監(jiān)聽套接字中獲取新連接,每當獲取到一個新連接后就創(chuàng)建一個新線程為該連接提供服務。
代碼如下:
//HTTP服務器
class HttpServer{
private:
int _port; //端口號
public:
HttpServer(int port)
:_port(port)
{}
//啟動服務器
void Loop()
{
LOG(INFO, "loop begin");
TcpServer* tsvr = TcpServer::GetInstance(_port); //獲取TCP服務器單例對象
int listen_sock = tsvr->Sock(); //獲取監(jiān)聽套接字
while(true){
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //獲取新連接
if(sock < 0){
continue; //獲取失敗,繼續(xù)獲取
}
//打印客戶端相關(guān)信息
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
//創(chuàng)建新線程處理新連接發(fā)起的HTTP請求
int* p = new int(sock);
pthread_t tid;
pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
pthread_detach(tid); //線程分離
}
}
~HttpServer()
{}
};
說明一下:
- 服務器需要將新連接對應的套接字作為參數(shù)傳遞給新線程,為了避免該套接字在新線程讀取之前被下一次獲取到的套接字覆蓋,因此在傳遞套接字時最好重新new一塊空間來存儲套接字的值。
- 新線程創(chuàng)建后可以將新線程分離,分離后主線程繼續(xù)獲取新連接,而新線程則處理新連接發(fā)來的HTTP請求,代碼中的HandlerRequest函數(shù)就是新線程處理新連接時需要執(zhí)行的回調(diào)函數(shù)。
主函數(shù)邏輯
運行服務器時要求指定服務器的端口號,我們用這個端口號創(chuàng)建一個HttpServer對象,然后調(diào)用Loop函數(shù)運行服務器,此時服務器就會不斷獲取新連接并創(chuàng)建新線程來處理連接。
代碼如下:
{
std::cout<<"Usage:nt"<}
int main(int argc, char* argv[])
{
if(argc != 2){
Usage(argv[0]);
exit(4);
}
int port = atoi(argv[1]); //端口號
std::shared_ptr svr(new HttpServer(port)); //創(chuàng)建HTTP服務器對象
svr->Loop(); //啟動服務器
return 0;
}<<">
HTTP請求結(jié)構(gòu)設計
HTTP請求類
我們可以將HTTP請求封裝成一個類,這個類當中包括HTTP請求的內(nèi)容、HTTP請求的解析結(jié)果以及是否需要使用CGI模式的標志位。后續(xù)處理請求時就可以定義一個HTTP請求類,讀取到的HTTP請求的數(shù)據(jù)就存儲在這個類當中,解析HTTP請求后得到的數(shù)據(jù)也存儲在這個類當中。
代碼如下:
class HttpRequest{
public:
//HTTP請求內(nèi)容
std::string _request_line; //請求行
std::vector _request_header; //請求報頭
std::string _blank; //空行
std::string _request_body; //請求正文
//解析結(jié)果
std::string _method; //請求方法
std::string _uri; //URI
std::string _version; //版本號
std::unordered_map _header_kv; //請求報頭中的鍵值對
int _content_length; //正文長度
std::string _path; //請求資源的路徑
std::string _query_string; //uri中攜帶的參數(shù)
//CGI相關(guān)
bool _cgi; //是否需要使用CGI模式
public:
HttpRequest()
:_content_length(0) //默認請求正文長度為0
,_cgi(false) //默認不使用CGI模式
{}
~HttpRequest()
{}
};,>
HTTP響應結(jié)構(gòu)設計
HTTP響應類
HTTP響應也可以封裝成一個類,這個類當中包括HTTP響應的內(nèi)容以及構(gòu)建HTTP響應所需要的數(shù)據(jù)。后續(xù)構(gòu)建響應時就可以定義一個HTTP響應類,構(gòu)建響應需要使用的數(shù)據(jù)就存儲在這個類當中,構(gòu)建后得到的響應內(nèi)容也存儲在這個類當中。
代碼如下:
class HttpResponse{
public:
//HTTP響應內(nèi)容
std::string _status_line; //狀態(tài)行
std::vector _response_header; //響應報頭
std::string _blank; //空行
std::string _response_body; //響應正文(CGI相關(guān))
//所需數(shù)據(jù)
int _status_code; //狀態(tài)碼
int _fd; //響應文件的fd (非CGI相關(guān))
int _size; //響應文件的大?。ǚ荂GI相關(guān))
std::string _suffix; //響應文件的后綴(非CGI相關(guān))
public:
HttpResponse()
:_blank(LINE_END) //設置空行
,_status_code(OK) //狀態(tài)碼默認為200
,_fd(-1) //響應文件的fd初始化為-1
,_size(0) //響應文件的大小默認為0
{}
~HttpResponse()
{}
};
EndPoint類編寫
EndPoint結(jié)構(gòu)設計
EndPoint結(jié)構(gòu)設計
EndPoint這個詞經(jīng)常用來描述進程間通信,比如在客戶端和服務器通信時,客戶端是一個EndPoint,服務器則是另一個EndPoint,因此這里將處理請求的類取名為EndPoint。
EndPoint類中包含三個成員變量:
- sock:表示與客戶端進行通信的套接字。
- http_request:表示客戶端發(fā)來的HTTP請求。
- http_response:表示將會發(fā)送給客戶端的HTTP響應。
EndPoint類中主要包含四個成員函數(shù):
- RecvHttpRequest:讀取客戶端發(fā)來的HTTP請求。
- HandlerHttpRequest:處理客戶端發(fā)來的HTTP請求。
- BuildHttpResponse:構(gòu)建將要發(fā)送給客戶端的HTTP響應。
- SendHttpResponse:發(fā)送HTTP響應給客戶端。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
EndPoint(int sock)
:_sock(sock)
{}
//讀取請求
void RecvHttpRequest();
//處理請求
void HandlerHttpRequest();
//構(gòu)建響應
void BuildHttpResponse();
//發(fā)送響應
void SendHttpResponse();
~EndPoint()
{}
};
設計線程回調(diào)
設計線程回調(diào)
服務器每獲取到一個新連接就會創(chuàng)建一個新線程來進行處理,而這個線程要做的實際就是定義一個EndPoint對象,然后依次進行讀取請求、處理請求、構(gòu)建響應、發(fā)送響應,處理完畢后將與客戶端建立的套接字關(guān)閉即可。
代碼如下:
public:
static void* HandlerRequest(void* arg)
{
LOG(INFO, "handler request begin");
int sock = *(int*)arg;
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //讀取請求
ep->HandlerHttpRequest(); //處理請求
ep->BuildHttpResponse(); //構(gòu)建響應
ep->SendHttpResponse(); //發(fā)送響應
close(sock); //關(guān)閉與該客戶端建立的套接字
delete ep;
LOG(INFO, "handler request end");
return nullptr;
}
};
讀取HTTP請求
讀取HTTP請求
讀取HTTP請求的同時可以對HTTP請求進行解析,這里我們分為五個步驟,分別是讀取請求行、讀取請求報頭和空行、解析請求行、解析請求報頭、讀取請求正文。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
//讀取請求
void RecvHttpRequest()
{
RecvHttpRequestLine(); //讀取請求行
RecvHttpRequestHeader(); //讀取請求報頭和空行
ParseHttpRequestLine(); //解析請求行
ParseHttpRequestHeader(); //解析請求報頭
RecvHttpRequestBody(); //讀取請求正文
}
};
一、讀取請求行
讀取請求行很簡單,就是從套接字中讀取一行內(nèi)容存儲到HTTP請求類中的request_line中即可。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//讀取請求行
void RecvHttpRequestLine()
{
auto& line = _http_request._request_line;
if(Util::ReadLine(_sock, line) > 0){
line.resize(line.size() - 1); //去掉讀取上來的n
}
}
};
需要注意的是,這里在按行讀取HTTP請求時,不能直接使用C/C++提供的gets或getline函數(shù)進行讀取,因為不同平臺下的行分隔符可能是不一樣的,可能是r、n或者rn。
比如下面是用WFetch請求百度首頁時得到的HTTP響應,可以看到其中使用的行分隔符就是rn:

因此我們這里需要自己寫一個ReadLine函數(shù),以確保能夠兼容這三種行分隔符。我們可以把這個函數(shù)寫到一個工具類當中,后續(xù)編寫的處理字符串的函數(shù)也都寫到這個類當中。
ReadLine函數(shù)的處理邏輯如下:
- 從指定套接字中讀取一個個字符。
- 如果讀取到的字符既不是n也不是r,則將讀取到的字符push到用戶提供的緩沖區(qū)后繼續(xù)讀取下一個字符。
- 如果讀取到的字符是n,則說明行分隔符是n,此時將npush到用戶提供的緩沖區(qū)后停止讀取。
- 如果讀取到的字符是r,則需要繼續(xù)窺探下一個字符是否是n,如果窺探成功則說明行分隔符為rn,此時將未讀取的n讀取上來后,將npush到用戶提供的緩沖區(qū)后停止讀?。蝗绻Q探失敗則說明行分隔符是r,此時也將npush到用戶提供的緩沖區(qū)后停止讀取。
也就是說,無論是哪一種行分隔符,最終讀取完一行后我們都把npush到了用戶提供的緩沖區(qū)當中,相當于將這三種行分隔符統(tǒng)一轉(zhuǎn)換成了以n為行分隔符,只不過最終我們把n一同讀取到了用戶提供的緩沖區(qū)中罷了,因此如果調(diào)用者不需要讀取上來的n,需要后續(xù)自行將其去掉。
代碼如下:
class Util{
public:
//讀取一行
static int ReadLine(int sock, std::string& out)
{
char ch = 'X'; //ch只要不初始化為n即可(保證能夠進入while循環(huán))
while(ch != 'n'){
ssize_t size = recv(sock, &ch, 1, 0);
if(size > 0){
if(ch == 'r'){
//窺探下一個字符是否為n
recv(sock, &ch, 1, MSG_PEEK);
if(ch == 'n'){ //下一個字符是n
//rn->n
recv(sock, &ch, 1, 0); //將這個n讀走
}
else{ //下一個字符不是n
//r->n
ch = 'n'; //將ch設置為n
}
}
//普通字符或n
out.push_back(ch);
}
else if(size == 0){ //對方關(guān)閉連接
return 0;
}
else{ //讀取失敗
return -1;
}
}
return out.size(); //返回讀取到的字符個數(shù)
}
};
說明一下:recv函數(shù)的最后一個參數(shù)如果設置為MSG_PEEK,那么recv函數(shù)將返回TCP接收緩沖區(qū)頭部指定字節(jié)個數(shù)的數(shù)據(jù),但是并不把這些數(shù)據(jù)從TCP接收緩沖區(qū)中取走,這個叫做數(shù)據(jù)的窺探功能。
二、讀取請求報頭和空行
由于HTTP的請求報頭和空行都是按行陳列的,因此可以循環(huán)調(diào)用ReadLine函數(shù)進行讀取,并將讀取到的每行數(shù)據(jù)都存儲到HTTP請求類的request_header中,直到讀取到空行為止。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//讀取請求報頭和空行
void RecvHttpRequestHeader()
{
std::string line;
while(true){
line.clear(); //每次讀取之前清空line
Util::ReadLine(_sock, line);
if(line == "n"){ //讀取到了空行
_http_request._blank = line;
break;
}
//讀取到一行請求報頭
line.resize(line.size() - 1); //去掉讀取上來的n
_http_request._request_header.push_back(line);
}
}
};
說明一下:
- 由于ReadLine函數(shù)是將讀取到的數(shù)據(jù)直接push_back到用戶提供的緩沖區(qū)中的,因此每次調(diào)用ReadLine函數(shù)進行讀取之前需要將緩沖區(qū)清空。
- ReadLine函數(shù)會將行分隔符n一同讀取上來,但對于我們來說n并不是有效數(shù)據(jù),因此在將讀取到的行存儲到HTTP請求類的request_header中之前,需要先將n去掉。
三、解析請求行
解析請求行要做的就是將請求行中的請求方法、URI和HTTP版本號拆分出來,依次存儲到HTTP請求類的method、uri和version中,由于請求行中的這些數(shù)據(jù)都是以空格作為分隔符的,因此可以借助一個stringstream對象來進行拆分。此外,為了后續(xù)能夠正確判斷用戶的請求方法,這里需要通過transform函數(shù)統(tǒng)一將請求方法轉(zhuǎn)換為全大寫。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//解析請求行
void ParseHttpRequestLine()
{
auto& line = _http_request._request_line;
//通過stringstream拆分請求行
std::stringstream ss(line);
ss>>_http_request._method>>_http_request._uri>>_http_request._version;
//將請求方法統(tǒng)一轉(zhuǎn)換為全大寫
auto& method = _http_request._method;
std::transform(method.begin(), method.end(), method.begin(), toupper);
}
};
四、解析請求報頭
解析請求報頭要做的就是將讀取到的一行一行的請求報頭,以: 為分隔符拆分成一個個的鍵值對存儲到HTTP請求的header_kv中,后續(xù)就可以直接通過屬性名獲取到對應的值了。
代碼如下:
//服務端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//解析請求報頭
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
for(auto& iter : _http_request._request_header){
//將每行請求報頭打散成kv鍵值對,插入到unordered_map中
if(Util::CutString(iter, key, value, SEP)){
_http_request._header_kv.insert({key, value});
}
}
}
};
此處用于切割字符串的CutString函數(shù)也可以寫到工具類中,切割字符串時先通過find方法找到指定的分隔符,然后通過substr提取切割后的子字符串即可。
代碼如下:
class Util{
public:
//切割字符串
static bool CutString(std::string& target, std::string& sub1_out, std::string& sub2_out, std::string sep)
{
size_t pos = target.find(sep, 0);
if(pos != std::string::npos){
sub1_out = target.substr(0, pos);
sub2_out = target.substr(pos + sep.size());
return true;
}
return false;
}
};
五、讀取請求正文
在讀取請求正文之前,首先需要通過本次的請求方法來判斷是否需要讀取請求正文,因為只有請求方法是POST方法才可能會有請求正文,此外,如果請求方法為POST,我們還需要通過請求報頭中的Content-Length屬性來得知請求正文的長度。
在得知需要讀取請求正文以及請求正文的長度后,就可以將請求正文讀取到HTTP請求類的request_body中了。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//判斷是否需要讀取請求正文
bool IsNeedRecvHttpRequestBody()
{
auto& method = _http_request._method;
if(method == "POST"){ //請求方法為POST則需要讀取正文
auto& header_kv = _http_request._header_kv;
//通過Content-Length獲取請求正文長度
auto iter = header_kv.find("Content-Length");
if(iter != header_kv.end()){
_http_request._content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
//讀取請求正文
void RecvHttpRequestBody()
{
if(IsNeedRecvHttpRequestBody()){ //先判斷是否需要讀取正文
int content_length = _http_request._content_length;
auto& body = _http_request._request_body;
//讀取請求正文
char ch = 0;
while(content_length){
ssize_t size = recv(_sock, &ch, 1, 0);
if(size > 0){
body.push_back(ch);
content_length--;
}
else{
break;
}
}
}
}
};
說明一下:
- 由于后續(xù)還會用到請求正文的長度,因此代碼中將其存儲到了HTTP請求類的content_length中。
- 在通過Content-Length獲取到請求正文的長度后,需要將請求正文長度從字符串類型轉(zhuǎn)換為整型。
處理HTTP請求
定義狀態(tài)碼
在處理請求的過程中可能會因為某些原因而直接停止處理,比如請求方法不正確、請求資源不存在或服務器處理請求時出錯等等。為了告知客戶端本次HTTP請求的處理情況,服務器需要定義不同的狀態(tài)碼,當處理請求被終止時就可以設置對應的狀態(tài)碼,后續(xù)構(gòu)建HTTP響應的時候就可以根據(jù)狀態(tài)碼返回對應的錯誤頁面。
狀態(tài)碼定義如下:
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define INTERNAL_SERVER_ERROR 500
處理HTTP請求
處理HTTP請求的步驟如下:
- 判斷請求方法是否是正確,如果不正確則設置狀態(tài)碼為BAD_REQUEST后停止處理。
- 如果請求方法為GET方法,則需要判斷URI中是否帶參。如果URI不帶參,則說明URI即為客戶端請求的資源路徑;如果URI帶參,則需要以?為分隔符對URI進行字符串切分,切分后?左邊的內(nèi)容就是客戶端請求的資源路徑,而?右邊的內(nèi)容則是GET方法攜帶的參數(shù),由于此時GET方法攜帶了參數(shù),因此后續(xù)處理需要使用CGI模式,于是需要將HTTP請求類中的cgi設置為true。
- 如果請求方法為POST方法,則說明URI即為客戶端請求的資源路徑,由于POST方法會通過請求正文上傳參數(shù),因此后續(xù)處理需要使用CGI模式,于是需要將HTTP請求類中的cgi設置為true。
- 接下來需要對客戶端請求的資源路徑進行處理,首先需要在請求的資源路徑前拼接上web根目錄,然后需要判斷請求資源路徑的最后一個字符是否是/,如果是則說明客戶端請求的是一個目錄,這時服務器不會將該目錄下全部的資源都返回給客戶端,而是默認將該目錄下的index.html返回給客戶端,因此這時還需要在請求資源路徑的后面拼接上index.html。
- 對請求資源的路徑進行處理后,需要通過stat函數(shù)獲取客戶端請求資源文件的屬性信息。如果客戶端請求的是一個目錄,則需要在請求資源路徑的后面拼接上/index.html并重新獲取資源文件的屬性信息;如果客戶端請求的是一個可執(zhí)行程序,則說明后續(xù)處理需要使用CGI模式,于是需要將HTTP請求類中的cgi設置為true。
- 根據(jù)HTTP請求類中的cgi分別進行CGI或非CGI處理。
代碼如下:
#define HOME_PAGE "index.html"
//服務端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
//處理請求
void HandlerHttpRequest()
{
auto& code = _http_response._status_code;
if(_http_request._method != "GET"&&_http_request._method != "POST"){ //非法請求
LOG(WARNING, "method is not right");
code = BAD_REQUEST; //設置對應的狀態(tài)碼,并直接返回
return;
}
if(_http_request._method == "GET"){
size_t pos = _http_request._uri.find('?');
if(pos != std::string::npos){ //uri中攜帶參數(shù)
//切割uri,得到客戶端請求資源的路徑和uri中攜帶的參數(shù)
Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
_http_request._cgi = true; //上傳了參數(shù),需要使用CGI模式
}
else{ //uri中沒有攜帶參數(shù)
_http_request._path = _http_request._uri; //uri即是客戶端請求資源的路徑
}
}
else if(_http_request._method == "POST"){
_http_request._path = _http_request._uri; //uri即是客戶端請求資源的路徑
_http_request._cgi = true; //上傳了參數(shù),需要使用CGI模式
}
else{
//Do Nothing
}
//給請求資源路徑拼接web根目錄
std::string path = _http_request._path;
_http_request._path = WEB_ROOT;
_http_request._path += path;
//請求資源路徑以/結(jié)尾,說明請求的是一個目錄
if(_http_request._path[_http_request._path.size() - 1] == '/'){
//拼接上該目錄下的index.html
_http_request._path += HOME_PAGE;
}
//獲取請求資源文件的屬性信息
struct stat st;
if(stat(_http_request._path.c_str(), &st) == 0){ //屬性信息獲取成功,說明該資源存在
if(S_ISDIR(st.st_mode)){ //該資源是一個目錄
_http_request._path += "/"; //需要拼接/,以/結(jié)尾的目錄前面已經(jīng)處理過了
_http_request._path += HOME_PAGE; //拼接上該目錄下的index.html
stat(_http_request._path.c_str(), &st); //需要重新資源文件的屬性信息
}
else if(st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH){ //該資源是一個可執(zhí)行程序
_http_request._cgi = true; //需要使用CGI模式
}
_http_response._size = st.st_size; //設置請求資源文件的大小
}
else{ //屬性信息獲取失敗,可以認為該資源不存在
LOG(WARNING, _http_request._path + " NOT_FOUND");
code = NOT_FOUND; //設置對應的狀態(tài)碼,并直接返回
return;
}
//獲取請求資源文件的后綴
size_t pos = _http_request._path.rfind('.');
if(pos == std::string::npos){
_http_response._suffix = ".html"; //默認設置
}
else{
_http_response._suffix = _http_request._path.substr(pos);
}
//進行CGI或非CGI處理
if(_http_request._cgi == true){
code = ProcessCgi(); //以CGI的方式進行處理
}
else{
code = ProcessNonCgi(); //簡單的網(wǎng)頁返回,返回靜態(tài)網(wǎng)頁
}
}
};
說明一下:
- 本項目實現(xiàn)的HTTP服務器只支持GET方法和POST方法,因此如果客戶端發(fā)來的HTTP請求中不是這兩種方法則認為請求方法錯誤,如果想讓服務器支持其他的請求方法則直接增加對應的邏輯即可。
- 服務器向外提供的資源都會放在web根目錄下,比如網(wǎng)頁、圖片、視頻等資源,本項目中的web根目錄取名為wwwroot。web根目錄下的所有子目錄下都會有一個首頁文件,當用戶請求的資源是一個目錄時,就會默認返回該目錄下的首頁文件,本項目中的首頁文件取名為index.html。
- stat是一個系統(tǒng)調(diào)用函數(shù),它可以獲取指定文件的屬性信息,包括文件的inode編號、文件的權(quán)限、文件的大小等。如果調(diào)用stat函數(shù)獲取文件的屬性信息失敗,則可以認為客戶端請求的這個資源文件不存在,此時直接設置狀態(tài)碼為NOT_FOUND后停止處理即可。
- 當獲取文件的屬性信息后發(fā)現(xiàn)該文件是一個目錄,此時請求資源路徑一定不是以/結(jié)尾的,因為在此之前已經(jīng)對/結(jié)尾的請求資源路徑進行過處理了,因此這時需要給請求資源路徑拼接上/index.html。
- 只要一個文件的擁有者、所屬組、other其中一個具有可執(zhí)行權(quán)限,則說明這是一個可執(zhí)行文件,此時就需要將HTTP請求類中的cgi設置為true。
- 由于后續(xù)構(gòu)建HTTP響應時需要用到請求資源文件的后綴,因此代碼中對請求資源路徑通過從后往前找.的方式,來獲取請求資源文件的后綴,如果沒有找到.則默認請求資源的后綴為.html。
- 由于請求資源文件的大小后續(xù)可能會用到,因此在獲取到請求資源文件的屬性后,可以將請求資源文件的大小保存到HTTP響應類的size中。
CGI處理
CGI處理時需要創(chuàng)建子進程進行進程程序替換,但是在創(chuàng)建子進程之前需要先創(chuàng)建兩個匿名管道。這里站在父進程角度對這兩個管道進行命名,父進程用于讀取數(shù)據(jù)的管道叫做input,父進程用于寫入數(shù)據(jù)的管道叫做output。
示意圖如下:
創(chuàng)建匿名管道并創(chuàng)建子進程后,需要父子進程各自關(guān)閉兩個管道對應的讀寫端:
- 對于父進程來說,input管道是用來讀數(shù)據(jù)的,因此父進程需要保留input[0]關(guān)閉input[1],而output管道是用來寫數(shù)據(jù)的,因此父進程需要保留output[1]關(guān)閉output[0]。
- 對于子進程來說,input管道是用來寫數(shù)據(jù)的,因此子進程需要保留input[1]關(guān)閉input[0],而output管道是用來讀數(shù)據(jù)的,因此子進程需要保留output[0]關(guān)閉output[1]。
此時父子進程之間的通信信道已經(jīng)建立好了,但為了讓替換后的CGI程序從標準輸入讀取數(shù)據(jù)等價于從管道讀取數(shù)據(jù),向標準輸出寫入數(shù)據(jù)等價于向管道寫入數(shù)據(jù),因此在子進程進行進程程序替換之前,還需要對子進程進行重定向。
假設子進程保留的input[1]和output[0]對應的文件描述符分別是3和4,那么子進程對應的文件描述符表的指向大致如下:

現(xiàn)在我們要做的就是將子進程的標準輸入重定向到output管道,將子進程的標準輸出重定向到input管道,也就是讓子進程的0號文件描述符指向output管道,讓子進程的1號文件描述符指向input管道。
示意圖如下:

此外,在子進程進行進程程序替換之前,還需要進行各種參數(shù)的傳遞:
- 首先需要將請求方法通過putenv函數(shù)導入環(huán)境變量,以供CGI程序判斷應該以哪種方式讀取父進程傳遞過來的參數(shù)。
- 如果請求方法為GET方法,則需要將URL中攜帶的參數(shù)通過導入環(huán)境變量的方式傳遞給CGI程序。
- 如果請求方法為POST方法,則需要將請求正文的長度通過導入環(huán)境變量的方式傳遞給CGI程序,以供CGI程序判斷應該從管道讀取多少個參數(shù)。
此時子進程就可以進行進程程序替換了,而父進程需要做如下工作:
- 如果請求方法為POST方法,則父進程需要將請求正文中的參數(shù)寫入管道中,以供被替換后的CGI程序進行讀取。
- 然后父進程要做的就是不斷調(diào)用read函數(shù),從管道中讀取CGI程序?qū)懭氲奶幚斫Y(jié)果,并將其保存到HTTP響應類的response_body當中。
- 管道中的數(shù)據(jù)讀取完畢后,父進程需要調(diào)用waitpid函數(shù)等待CGI程序退出,并關(guān)閉兩個管道對應的文件描述符,防止文件描述符泄露。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//CGI處理
int ProcessCgi()
{
int code = OK; //要返回的狀態(tài)碼,默認設置為200
auto& bin = _http_request._path; //需要執(zhí)行的CGI程序
auto& method = _http_request._method; //請求方法
//需要傳遞給CGI程序的參數(shù)
auto& query_string = _http_request._query_string; //GET
auto& request_body = _http_request._request_body; //POST
int content_length = _http_request._content_length; //請求正文的長度
auto& response_body = _http_response._response_body; //CGI程序的處理結(jié)果放到響應正文當中
//1、創(chuàng)建兩個匿名管道(管道命名站在父進程角度)
//創(chuàng)建從子進程到父進程的通信信道
int input[2];
if(pipe(input) < 0){ //管道創(chuàng)建失敗,則返回對應的狀態(tài)碼
LOG(ERROR, "pipe input error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
//創(chuàng)建從父進程到子進程的通信信道
int output[2];
if(pipe(output) < 0){ //管道創(chuàng)建失敗,則返回對應的狀態(tài)碼
LOG(ERROR, "pipe output error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
//2、創(chuàng)建子進程
pid_t pid = fork();
if(pid == 0){ //child
//子進程關(guān)閉兩個管道對應的讀寫端
close(input[0]);
close(output[1]);
//將請求方法通過環(huán)境變量傳參
std::string method_env = "METHOD=";
method_env += method;
putenv((char*)method_env.c_str());
if(method == "GET"){ //將query_string通過環(huán)境變量傳參
std::string query_env = "QUERY_STRING=";
query_env += query_string;
putenv((char*)query_env.c_str());
LOG(INFO, "GET Method, Add Query_String env");
}
else if(method == "POST"){ //將正文長度通過環(huán)境變量傳參
std::string content_length_env = "CONTENT_LENGTH=";
content_length_env += std::to_string(content_length);
putenv((char*)content_length_env.c_str());
LOG(INFO, "POST Method, Add Content_Length env");
}
else{
//Do Nothing
}
//3、將子進程的標準輸入輸出進行重定向
dup2(output[0], 0); //標準輸入重定向到管道的輸入
dup2(input[1], 1); //標準輸出重定向到管道的輸出
//4、將子進程替換為對應的CGI程序
execl(bin.c_str(), bin.c_str(), nullptr);
exit(1); //替換失敗
}
else if(pid < 0){ //創(chuàng)建子進程失敗,則返回對應的錯誤碼
LOG(ERROR, "fork error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
else{ //father
//父進程關(guān)閉兩個管道對應的讀寫端
close(input[1]);
close(output[0]);
if(method == "POST"){ //將正文中的參數(shù)通過管道傳遞給CGI程序
const char* start = request_body.c_str();
int total = 0;
int size = 0;
while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0){
total += size;
}
}
//讀取CGI程序的處理結(jié)果
char ch = 0;
while(read(input[0], &ch, 1) > 0){
response_body.push_back(ch);
} //不會一直讀,當另一端關(guān)閉后會繼續(xù)執(zhí)行下面的代碼
//等待子進程(CGI程序)退出
int status = 0;
pid_t ret = waitpid(pid, &status, 0);
if(ret == pid){
if(WIFEXITED(status)){ //正常退出
if(WEXITSTATUS(status) == 0){ //結(jié)果正確
LOG(INFO, "CGI program exits normally with correct results");
code = OK;
}
else{
LOG(INFO, "CGI program exits normally with incorrect results");
code = BAD_REQUEST;
}
}
else{
LOG(INFO, "CGI program exits abnormally");
code = INTERNAL_SERVER_ERROR;
}
}
//關(guān)閉兩個管道對應的文件描述符
close(input[0]);
close(output[1]);
}
return code; //返回狀態(tài)碼
}
};
說明一下:
- 在CGI處理過程中,如果管道創(chuàng)建失敗或者子進程創(chuàng)建失敗,則屬于服務器端處理請求時出錯,此時返回INTERNAL_SERVER_ERROR狀態(tài)碼后停止處理即可。
- 環(huán)境變量是key=value形式的,因此在調(diào)用putenv函數(shù)導入環(huán)境變量前需要先正確構(gòu)建環(huán)境變量,此后被替換的CGI程序在調(diào)用getenv函數(shù)時,就可以通過key獲取到對應的value。
- 子進程傳遞參數(shù)的代碼最好放在重定向之前,否則服務器運行后無法看到傳遞參數(shù)對應的日志信息,因為日志是以cout的方式打印到標準輸出的,而dup2函數(shù)調(diào)用后標準輸出已經(jīng)被重定向到了管道,此時打印的日志信息將會被寫入管道。
- 父進程循環(huán)調(diào)用read函數(shù)從管道中讀取CGI程序的處理結(jié)果,當CGI程序執(zhí)行結(jié)束時相當于寫端進程將寫端關(guān)閉了(文件描述符的生命周期隨進程),此時讀端進程將管道當中的數(shù)據(jù)讀完后,就會繼續(xù)執(zhí)行后續(xù)代碼,而不會被阻塞。
- 父進程在等待子進程退出后,可以通過WIFEXITED判斷子進程是否是正常退出,如果是正常退出再通過WEXITSTATUS判斷處理結(jié)果是否正確,然后根據(jù)不同情況設置對應的狀態(tài)碼(此時就算子進程異常退出或處理結(jié)果不正確也不能立即返回,需要讓父進程繼續(xù)向后執(zhí)行,關(guān)閉兩個管道對應的文件描述符,防止文件描述符泄露)。
非CGI處理
非CGI處理時只需要將客戶端請求的資源構(gòu)建成HTTP響應發(fā)送給客戶端即可,理論上這里要做的就是打開目標文件,將文件中的內(nèi)容讀取到HTTP響應類的response_body中,以供后續(xù)發(fā)送HTTP響應時進行發(fā)送即可,但我們并不推薦這種做法。
因為HTTP響應類的response_body屬于用戶層的緩沖區(qū),而目標文件是存儲在服務器的磁盤上的,按照這種方式需要先將文件內(nèi)容讀取到內(nèi)核層緩沖區(qū),再由操作系統(tǒng)將其拷貝到用戶層緩沖區(qū),發(fā)送響應正文的時候又需要先將其拷貝到內(nèi)核層緩沖區(qū),再由操作系統(tǒng)將其發(fā)送給對應的網(wǎng)卡進行發(fā)送。
示意圖如下:
可以看到上述過程涉及數(shù)據(jù)在用戶層和內(nèi)核層的來回拷貝,但實際這個拷貝操作是不需要的,我們完全可以直接將磁盤當中的目標文件內(nèi)容讀取到內(nèi)核,再由內(nèi)核將其發(fā)送給對應的網(wǎng)卡進行發(fā)送。
示意圖如下:

要達到上述效果就需要使用sendfile函數(shù),該函數(shù)的功能就是將數(shù)據(jù)從一個文件描述符拷貝到另一個文件描述符,并且這個拷貝操作是在內(nèi)核中完成的,因此sendfile比單純的調(diào)用read和write更加高效。
但是需要注意的是,這里還不能直接調(diào)用sendfile函數(shù),因為sendfile函數(shù)調(diào)用后文件內(nèi)容就發(fā)送出去了,而我們應該構(gòu)建HTTP響應后再進行發(fā)送,因此我們這里要做的僅僅是將要發(fā)送的目標文件打開即可,將打開文件對應的文件描述符保存到HTTP響應的fd當中。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
//非CGI處理
int ProcessNonCgi()
{
//打開客戶端請求的資源文件,以供后續(xù)發(fā)送
_http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
if(_http_response._fd >= 0){ //打開文件成功
return OK;
}
return INTERNAL_SERVER_ERROR; //打開文件失敗
}
};
說明一下: 如果打開文件失敗,則返回INTERNAL_SERVER_ERROR狀態(tài)碼表示服務器處理請求時出錯,而不能返回NOT_FOUND,因為之前調(diào)用stat獲取過客戶端請求資源的屬性信息,說明該資源文件是一定存在的。
構(gòu)建HTTP響應
構(gòu)建HTTP響應
構(gòu)建HTTP響應首先需要構(gòu)建的就是狀態(tài)行,狀態(tài)行由狀態(tài)碼、狀態(tài)碼描述、HTTP版本構(gòu)成,并以空格作為分隔符,將狀態(tài)行構(gòu)建好后保存到HTTP響應的status_line當中即可,而響應報頭需要根據(jù)請求是否正常處理完畢分別進行構(gòu)建。
代碼如下:
#define LINE_END "rn"
#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"
//服務端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
//構(gòu)建響應
void BuildHttpResponse()
{
int code = _http_response._status_code;
//構(gòu)建狀態(tài)行
auto& status_line = _http_response._status_line;
status_line += HTTP_VERSION;
status_line += " ";
status_line += std::to_string(code);
status_line += " ";
status_line += CodeToDesc(code);
status_line += LINE_END;
//構(gòu)建響應報頭
std::string path = WEB_ROOT;
path += "/";
switch(code){
case OK:
BuildOkResponse();
break;
case NOT_FOUND:
path += PAGE_404;
HandlerError(path);
break;
case BAD_REQUEST:
path += PAGE_400;
HandlerError(path);
break;
case INTERNAL_SERVER_ERROR:
path += PAGE_500;
HandlerError(path);
break;
default:
break;
}
}
};
注意:本項目中將服務器的行分隔符設置為rn,在構(gòu)建完狀態(tài)行以及每行響應報頭之后都需要加上對應的行分隔符,而在HTTP響應類的構(gòu)造函數(shù)中已經(jīng)將空行初始化為了LINE_END,因此在構(gòu)建HTTP響應時不用處理空行。
對于狀態(tài)行中的狀態(tài)碼描述,我們可以編寫一個函數(shù),該函數(shù)能夠根據(jù)狀態(tài)碼返回對應的狀態(tài)碼描述。
代碼如下:
static std::string CodeToDesc(int code)
{
std::string desc;
switch(code){
case 200:
desc = "OK";
break;
case 400:
desc = "Bad Request";
break;
case 404:
desc = "Not Found";
break;
case 500:
desc = "Internal Server Error";
break;
default:
break;
}
return desc;
}
構(gòu)建響應報頭(請求正常處理完畢)
構(gòu)建HTTP的響應報頭時,我們至少需要構(gòu)建Content-Type和Content-Length這兩個響應報頭,分別用于告知對方響應資源的類型和響應資源的長度。
對于請求正常處理完畢的HTTP請求,需要根據(jù)客戶端請求資源的后綴來得知返回資源的類型。而返回資源的大小需要根據(jù)該請求被處理的方式來得知,如果該請求是以非CGI方式進行處理的,那么返回資源的大小早已在獲取請求資源屬性時被保存到了HTTP響應類中的size當中,如果該請求是以CGI方式進行處理的,那么返回資源的大小應該是HTTP響應類中的response_body的大小。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
void BuildOkResponse()
{
//構(gòu)建響應報頭
std::string content_type = "Content-Type: ";
content_type += SuffixToDesc(_http_response._suffix);
content_type += LINE_END;
_http_response._response_header.push_back(content_type);
std::string content_length = "Content-Length: ";
if(_http_request._cgi){ //以CGI方式請求
content_length += std::to_string(_http_response._response_body.size());
}
else{ //以非CGI方式請求
content_length += std::to_string(_http_response._size);
}
content_length += LINE_END;
_http_response._response_header.push_back(content_length);
}
};
對于返回資源的類型,我們可以編寫一個函數(shù),該函數(shù)能夠根據(jù)文件后綴返回對應的文件類型。查看Content-Type轉(zhuǎn)化表可以得知后綴與文件類型的對應關(guān)系,將這個對應關(guān)系存儲一個unordered_map容器中,當需要根據(jù)后綴得知文件類型時直接在這個unordered_map容器中進行查找,如果找到了則返回對應的文件類型,如果沒有找到則默認該文件類型為text/html。
代碼如下:
static std::string SuffixToDesc(const std::string& suffix)
{
static std::unordered_map suffix_to_desc = {
{".html", "text/html"},
{".css", "text/css"},
{".js", "application/x-javascript"},
{".jpg", "application/x-jpg"},
{".xml", "text/xml"}
};
auto iter = suffix_to_desc.find(suffix);
if(iter != suffix_to_desc.end()){
return iter->second;
}
return "text/html"; //所給后綴未找到則默認該資源為html文件
},>
構(gòu)建響應報頭(請求處理出現(xiàn)錯誤)
對于請求處理過程中出現(xiàn)錯誤的HTTP請求,服務器將會為其返回對應的錯誤頁面,因此返回的資源類型就是text/html,而返回資源的大小可以通過獲取錯誤頁面對應的文件屬性信息來得知。此外,為了后續(xù)發(fā)送響應時可以直接調(diào)用sendfile進行發(fā)送,這里需要將錯誤頁面對應的文件打開,并將對應的文件描述符保存在HTTP響應類的fd當中。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
private:
void HandlerError(std::string page)
{
_http_request._cgi = false; //需要返回對應的錯誤頁面(非CGI返回)
//打開對應的錯誤頁面文件,以供后續(xù)發(fā)送
_http_response._fd = open(page.c_str(), O_RDONLY);
if(_http_response._fd > 0){ //打開文件成功
//構(gòu)建響應報頭
struct stat st;
stat(page.c_str(), &st); //獲取錯誤頁面文件的屬性信息
std::string content_type = "Content-Type: text/html";
content_type += LINE_END;
_http_response._response_header.push_back(content_type);
std::string content_length = "Content-Length: ";
content_length += std::to_string(st.st_size);
content_length += LINE_END;
_http_response._response_header.push_back(content_length);
_http_response._size = st.st_size; //重新設置響應文件的大小
}
}
};
特別注意:對于處理請求時出錯的HTTP請求,需要將其HTTP請求類中的cgi重新設置為false,因為后續(xù)發(fā)送HTTP響應時,需要根據(jù)HTTP請求類中的cgi來進行響應正文的發(fā)送,當請求處理出錯后要返回給客戶端的本質(zhì)就是一個錯誤頁面文件,相當于是以非CGI方式進行處理的。
發(fā)送HTTP響應
發(fā)送HTTP響應
發(fā)送HTTP響應的步驟如下:
- 調(diào)用send函數(shù),依次發(fā)送狀態(tài)行、響應報頭和空行。
- 發(fā)送響應正文時需要判斷本次請求的處理方式,如果本次請求是以CGI方式成功處理的,那么待發(fā)送的響應正文是保存在HTTP響應類的response_body中的,此時調(diào)用send函數(shù)進行發(fā)送即可。
- 如果本次請求是以非CGI方式處理或在處理過程中出錯的,那么待發(fā)送的資源文件或錯誤頁面文件對應的文件描述符是保存在HTTP響應類的fd中的,此時調(diào)用sendfile進行發(fā)送即可,發(fā)送后關(guān)閉對應的文件描述符。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
//發(fā)送響應
void SendHttpResponse()
{
//發(fā)送狀態(tài)行
send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);
//發(fā)送響應報頭
for(auto& iter : _http_response._response_header){
send(_sock, iter.c_str(), iter.size(), 0);
}
//發(fā)送空行
send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);
//發(fā)送響應正文
if(_http_request._cgi){
auto& response_body = _http_response._response_body;
const char* start = response_body.c_str();
size_t size = 0;
size_t total = 0;
while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
total += size;
}
}
else{
sendfile(_sock, _http_response._fd, nullptr, _http_response._size);
//關(guān)閉請求的資源文件
close(_http_response._fd);
}
}
};
差錯處理
至此服務器邏輯其實已經(jīng)已經(jīng)走通了,但你會發(fā)現(xiàn)服務器在處理請求的過程中有時會莫名其妙的崩潰,根本原因就是當前服務器的錯誤處理還沒有完全處理完畢。
邏輯錯誤
邏輯錯誤
邏輯錯誤主要是服務器在處理請求的過程中出現(xiàn)的一些錯誤,比如請求方法不正確、請求資源不存在或服務器處理請求時出錯等等。邏輯錯誤其實我們已經(jīng)處理過了,當出現(xiàn)這類錯誤時服務器會將對應的錯誤頁面返回給客戶端。
讀取錯誤
讀取錯誤
邏輯錯誤是在服務器處理請求時可能出現(xiàn)的錯誤,而在服務器處理請求之前首先要做的是讀取請求,在讀取請求的過程中出現(xiàn)的錯誤就叫做讀取錯誤,比如調(diào)用recv讀取請求時出錯或讀取請求時對方連接關(guān)閉等。
出現(xiàn)讀取錯誤時,意味著服務器都沒有成功讀取完客戶端發(fā)來的HTTP請求,因此服務器也沒有必要進行后續(xù)的處理請求、構(gòu)建響應以及發(fā)送響應的相關(guān)操作了。
可以在EndPoint類中新增一個bool類型的stop成員,表示是否停止本次處理,stop的值默認設置為false,當讀取請求出錯時就直接設置stop為true并不再進行后續(xù)的讀取操作,因此讀取HTTP請求的代碼需要稍作修改。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
bool _stop; //是否停止本次處理
private:
//讀取請求行
bool RecvHttpRequestLine()
{
auto& line = _http_request._request_line;
if(Util::ReadLine(_sock, line) > 0){
line.resize(line.size() - 1); //去掉讀取上來的n
}
else{ //讀取出錯,則停止本次處理
_stop = true;
}
return _stop;
}
//讀取請求報頭和空行
bool RecvHttpRequestHeader()
{
std::string line;
while(true){
line.clear(); //每次讀取之前清空line
if(Util::ReadLine(_sock, line) <= 0){ //讀取出錯,則停止本次處理
_stop = true;
break;
}
if(line == "n"){ //讀取到了空行
_http_request._blank = line;
break;
}
//讀取到一行請求報頭
line.resize(line.size() - 1); //去掉讀取上來的n
_http_request._request_header.push_back(line);
}
return _stop;
}
//讀取請求正文
bool RecvHttpRequestBody()
{
if(IsNeedRecvHttpRequestBody()){ //先判斷是否需要讀取正文
int content_length = _http_request._content_length;
auto& body = _http_request._request_body;
//讀取請求正文
char ch = 0;
while(content_length){
ssize_t size = recv(_sock, &ch, 1, 0);
if(size > 0){
body.push_back(ch);
content_length--;
}
else{ //讀取出錯或?qū)Χ岁P(guān)閉,則停止本次處理
_stop = true;
break;
}
}
}
return _stop;
}
public:
EndPoint(int sock)
:_sock(sock)
,_stop(false)
{}
//本次處理是否停止
bool IsStop()
{
return _stop;
}
//讀取請求
void RecvHttpRequest()
{
if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值
ParseHttpRequestLine();
ParseHttpRequestHeader();
RecvHttpRequestBody();
}
}
};
說明一下:
- 可以將讀取請求行、讀取請求報頭和空行、讀取請求正文對應函數(shù)的返回值改為bool類型,當讀取請求行成功后再讀取請求報頭和空行,而當讀取請求報頭和空行成功后才需要進行后續(xù)的解析請求行、解析請求報頭以及讀取請求正文操作,這里利用到了邏輯運算符的短路求值策略。
- EndPoint類當中提供了IsStop函數(shù),用于讓外部處理線程得知是否應該停止本次處理。
此時服務器創(chuàng)建的新線程在讀取請求后,就需要判斷是否應該停止本次處理,如果需要則不再進行處理請求、構(gòu)建響應以及發(fā)送響應操作,而直接關(guān)閉于客戶端建立的套接字即可。
代碼如下:
public:
static void* HandlerRequest(void* arg)
{
LOG(INFO, "handler request begin");
int sock = *(int*)arg;
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //讀取請求
if(!ep->IsStop()){
LOG(INFO, "Recv No Error, Begin Handler Request");
ep->HandlerHttpRequest(); //處理請求
ep->BuildHttpResponse(); //構(gòu)建響應
ep->SendHttpResponse(); //發(fā)送響應
}
else{
LOG(WARNING, "Recv Error, Stop Handler Request");
}
close(sock); //關(guān)閉與該客戶端建立的套接字
delete ep;
LOG(INFO, "handler request end");
return nullptr;
}
};
寫入錯誤
寫入錯誤
除了讀取請求時可能出現(xiàn)讀取錯誤,處理請求時可能出現(xiàn)邏輯錯誤,在響應構(gòu)建完畢發(fā)送響應時同樣可能會出現(xiàn)寫入錯誤,比如調(diào)用send發(fā)送響應時出錯或發(fā)送響應時對方連接關(guān)閉等。
出現(xiàn)寫入錯誤時,服務器也沒有必要繼續(xù)進行發(fā)送了,這時需要直接設置stop為true并不再進行后續(xù)的發(fā)送操作,因此發(fā)送HTTP響應的代碼也需要進行修改。
代碼如下:
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP請求
HttpResponse _http_response; //HTTP響應
public:
//發(fā)送響應
bool SendHttpResponse()
{
//發(fā)送狀態(tài)行
if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0){
_stop = true; //發(fā)送失敗,設置_stop
}
//發(fā)送響應報頭
if(!_stop){
for(auto& iter : _http_response._response_header){
if(send(_sock, iter.c_str(), iter.size(), 0) <= 0){
_stop = true; //發(fā)送失敗,設置_stop
break;
}
}
}
//發(fā)送空行
if(!_stop){
if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0){
_stop = true; //發(fā)送失敗,設置_stop
}
}
//發(fā)送響應正文
if(_http_request._cgi){
if(!_stop){
auto& response_body = _http_response._response_body;
const char* start = response_body.c_str();
size_t size = 0;
size_t total = 0;
while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
total += size;
}
}
}
else{
if(!_stop){
if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){
_stop = true; //發(fā)送失敗,設置_stop
}
}
//關(guān)閉請求的資源文件
close(_http_response._fd);
}
return _stop;
}
};
此外,當服務器發(fā)送響應出錯時會收到SIGPIPE信號,而該信號的默認處理動作是終止當前進程,為了防止服務器因為寫入出錯而被終止,需要在初始化HTTP服務器時調(diào)用signal函數(shù)忽略SIGPIPE信號。
代碼如下:
class HttpServer{
private:
int _port; //端口號
public:
//初始化服務器
void InitServer()
{
signal(SIGPIPE, SIG_IGN); //忽略SIGPIPE信號,防止寫入時崩潰
}
};
接入線程池
當前多線程版服務器存在的問題:
- 每當獲取到新連接時,服務器主線程都會重新為該客戶端創(chuàng)建為其提供服務的新線程,而當服務結(jié)束后又會將該新線程銷毀,這樣做不僅麻煩,而且效率低下。
- 如果同時有大量的客戶端連接請求,此時服務器就要為每一個客戶端創(chuàng)建對應的服務線程,而計算機中的線程越多,CPU壓力就越大,因為CPU要不斷在這些線程之間來回切換。此外,一旦線程過多,每一個線程再次被調(diào)度的周期就變長了,而線程是為客戶端提供服務的,線程被調(diào)度的周期變長,客戶端也就遲遲得不到應答。
這時可以在服務器端引入線程池:
- 在服務器端預先創(chuàng)建一批線程和一個任務隊列,每當獲取到一個新連接時就將其封裝成一個任務對象放到任務隊列當中。
- 線程池中的若干線程就不斷從任務隊列中獲取任務進行處理,如果任務隊列當中沒有任務則線程進入休眠狀態(tài),當有新任務時再喚醒線程進行任務處理。
示意圖如下:

設計任務
設計任務
當服務器獲取到一個新連接后,需要將其封裝成一個任務對象放到任務隊列當中。任務類中首先需要有一個套接字,也就是與客戶端進行通信的套接字,此外還需要有一個回調(diào)函數(shù),當線程池中的線程獲取到任務后就可以調(diào)用這個回調(diào)函數(shù)進行任務處理。
代碼如下:
class Task{
private:
int _sock; //通信的套接字
CallBack _handler; //回調(diào)函數(shù)
public:
Task()
{}
Task(int sock)
:_sock(sock)
{}
//處理任務
void ProcessOn()
{
_handler(_sock); //調(diào)用回調(diào)
}
~Task()
{}
};
說明一下: 任務類需要提供一個無參的構(gòu)造函數(shù),因為后續(xù)從任務隊列中獲取任務時,需要先以無參的方式定義一個任務對象,然后再以輸出型參數(shù)的方式來獲取任務。
編寫任務回調(diào)
任務類中處理任務時需要調(diào)用的回調(diào)函數(shù),實際就是之前創(chuàng)建新線程時傳入的執(zhí)行例程CallBack::HandlerRequest,我們可以將CallBack類的()運算符重載為調(diào)用HandlerRequest函數(shù),這時CallBack對象就變成了一個仿函數(shù)對象,這個仿函數(shù)對象被調(diào)用時實際就是在調(diào)用HandlerRequest函數(shù)。
代碼如下:
public:
CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock);
}
void HandlerRequest(int sock)
{
LOG(INFO, "handler request begin");
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //讀取請求
if(!ep->IsStop()){
LOG(INFO, "Recv No Error, Begin Handler Request");
ep->HandlerHttpRequest(); //處理請求
ep->BuildHttpResponse(); //構(gòu)建響應
ep->SendHttpResponse(); //發(fā)送響應
if(ep->IsStop()){
LOG(WARNING, "Send Error, Stop Send Response");
}
}
else{
LOG(WARNING, "Recv Error, Stop Handler Request");
}
close(sock); //關(guān)閉與該客戶端建立的套接字
delete ep;
LOG(INFO, "handler request end");
}
~CallBack()
{}
};
編寫線程池
設計線程池結(jié)構(gòu)
可以將線程池設計成單例模式:
- 將ThreadPool類的構(gòu)造函數(shù)設置為私有,并將拷貝構(gòu)造和拷貝賦值函數(shù)設置為私有或刪除,防止外部創(chuàng)建或拷貝對象。
- 提供一個指向單例對象的static指針,并在類外將其初始化為nullptr。
- 提供一個全局訪問點獲取單例對象,在單例對象第一次被獲取時就創(chuàng)建這個單例對象并進行初始化。
ThreadPool類中的成員變量包括:
- 任務隊列:用于暫時存儲未被處理的任務對象。
- num:表示線程池中線程的個數(shù)。
- 互斥鎖:用于保證任務隊列在多線程環(huán)境下的線程安全。
- 條件變量:當任務隊列中沒有任務時,讓線程在該條件變量下進行等等,當任務隊列中新增任務時,喚醒在該條件變量下進行等待的線程。
- 指向單例對象的指針:用于指向唯一的單例線程池對象。
ThreadPool類中的成員函數(shù)主要包括:
- 構(gòu)造函數(shù):完成互斥鎖和條件變量的初始化操作。
- 析構(gòu)函數(shù):完成互斥鎖和條件變量的釋放操作。
- InitThreadPool:初始化線程池時調(diào)用,完成線程池中若干線程的創(chuàng)建。
- PushTask:生產(chǎn)任務時調(diào)用,將任務對象放入任務隊列,并喚醒在條件變量下等待的一個線程進行處理。
- PopTask:消費任務時調(diào)用,從任務隊列中獲取一個任務對象。
- ThreadRoutine:線程池中每個線程的執(zhí)行例程,完成線程分離后不斷檢測任務隊列中是否有任務,如果有則調(diào)用PopTask獲取任務進行處理,如果沒有則進行休眠直到被喚醒。
- GetInstance:獲取單例線程池對象時調(diào)用,如果單例對象未創(chuàng)建則創(chuàng)建并初始化后返回,如果單例對象已經(jīng)創(chuàng)建則直接返回單例對象。
代碼如下:
//線程池
class ThreadPool{
private:
std::queue _task_queue; //任務隊列
int _num; //線程池中線程的個數(shù)
pthread_mutex_t _mutex; //互斥鎖
pthread_cond_t _cond; //條件變量
static ThreadPool* _inst; //指向單例對象的static指針
private:
//構(gòu)造函數(shù)私有
ThreadPool(int num = NUM)
:_num(num)
{
//初始化互斥鎖和條件變量
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
//將拷貝構(gòu)造函數(shù)和拷貝賦值函數(shù)私有或刪除(防拷貝)
ThreadPool(const ThreadPool&)=delete;
ThreadPool* operator=(const ThreadPool&)=delete;
//判斷任務隊列是否為空
bool IsEmpty()
{
return _task_queue.empty();
}
//任務隊列加鎖
void LockQueue()
{
pthread_mutex_lock(&_mutex);
}
//任務隊列解鎖
void UnLockQueue()
{
pthread_mutex_unlock(&_mutex);
}
//讓線程在條件變量下進行等待
void ThreadWait()
{
pthread_cond_wait(&_cond, &_mutex);
}
//喚醒在條件變量下等待的一個線程
void ThreadWakeUp()
{
pthread_cond_signal(&_cond);
}
public:
//獲取單例對象
static ThreadPool* GetInstance()
{
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定義靜態(tài)的互斥鎖
//雙檢查加鎖
if(_inst == nullptr){
pthread_mutex_lock(&mtx); //加鎖
if(_inst == nullptr){
//創(chuàng)建單例線程池對象并初始化
_inst = new ThreadPool();
_inst->InitThreadPool();
}
pthread_mutex_unlock(&mtx); //解鎖
}
return _inst; //返回單例對象
}
//線程的執(zhí)行例程
static void* ThreadRoutine(void* arg)
{
pthread_detach(pthread_self()); //線程分離
ThreadPool* tp = (ThreadPool*)arg;
while(true){
tp->LockQueue(); //加鎖
while(tp->IsEmpty()){
//任務隊列為空,線程進行wait
tp->ThreadWait();
}
Task task;
tp->PopTask(task); //獲取任務
tp->UnLockQueue(); //解鎖
task.ProcessOn(); //處理任務
}
}
//初始化線程池
bool InitThreadPool()
{
//創(chuàng)建線程池中的若干線程
pthread_t tid;
for(int i = 0;i < _num;i++){
if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
LOG(FATAL, "create thread pool error!");
return false;
}
}
LOG(INFO, "create thread pool success");
return true;
}
//將任務放入任務隊列
void PushTask(const Task& task)
{
LockQueue(); //加鎖
_task_queue.push(task); //將任務推入任務隊列
UnLockQueue(); //解鎖
ThreadWakeUp(); //喚醒一個線程進行任務處理
}
//從任務隊列中拿任務
void PopTask(Task& task)
{
//獲取任務
task = _task_queue.front();
_task_queue.pop();
}
~ThreadPool()
{
//釋放互斥鎖和條件變量
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
};
//單例對象指針初始化為nullptr
ThreadPool* ThreadPool::_inst = nullptr;
說明一下:
- 由于線程的執(zhí)行例程的參數(shù)只能有一個void*類型的參數(shù),因此線程的執(zhí)行例程必須定義成靜態(tài)成員函數(shù),而線程執(zhí)行例程中又需要訪問任務隊列,因此需要將this指針作為參數(shù)傳遞給線程的執(zhí)行例程,這樣線程才能夠通過this指針訪問任務隊列。
- 在向任務隊列中放任務以及從任務隊列中獲取任務時,都需要通過加鎖的方式來保證線程安全,而線程在調(diào)用PopTask之前已經(jīng)進行過加鎖了,因此在PopTask函數(shù)中不必再加鎖。
- 當任務隊列中有任務時會喚醒線程進行任務處理,為了防止被偽喚醒的線程調(diào)用PopTask時無法獲取到任務,因此需要以while的方式判斷任務隊列是否為空。
引入線程池后服務器要做的就是,每當獲取到一個新連接時就構(gòu)建一個任務,然后調(diào)用PushTask將其放入任務隊列即可。
代碼如下:
class HttpServer{
private:
int _port; //端口號
public:
//啟動服務器
void Loop()
{
LOG(INFO, "loop begin");
TcpServer* tsvr = TcpServer::GetInstance(_port); //獲取TCP服務器單例對象
int listen_sock = tsvr->Sock(); //獲取監(jiān)聽套接字
while(true){
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //獲取新連接
if(sock < 0){
continue; //獲取失敗,繼續(xù)獲取
}
//打印客戶端相關(guān)信息
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
//構(gòu)建任務并放入任務隊列中
Task task(sock);
ThreadPool::GetInstance()->PushTask(task);
}
}
};
項目測試
服務器結(jié)構(gòu)
至此HTTP服務器后端邏輯已經(jīng)全部編寫完畢,此時我們要做的就是將對外提供的資源文件放在一個名為wwwroot的目錄下,然后將生成的HTTP服務器可執(zhí)行程序與wwwroot放在同級目錄下。比如:
由于當前HTTP服務器沒有任何業(yè)務邏輯,因此向外提供的資源文件只有三個錯誤頁面文件,這些錯誤頁面文件中的內(nèi)容大致如下:
404 Not Found
對不起,你所要訪問的資源不存在!
首頁請求測試
服務器首頁編寫
服務器的web根目錄下的資源文件主要有兩種,一種就是用于處理客戶端上傳上來的數(shù)據(jù)的CGI程序,另一種就是供客戶端請求的各種網(wǎng)頁文件了,而網(wǎng)頁的制作實際是前端工程師要做的,但現(xiàn)在我們要對服務器進行測試,至少需要編寫一個首頁,首頁文件需要放在web根目錄下,取名為index.html。
以演示為主,首頁的代碼如下:
首頁請求測試
指定端口號運行服務器后可以看到一系列日志信息被打印出來,包括套接字創(chuàng)建成功、綁定成功、監(jiān)聽成功,這時底層用于通信的TCP服務器已經(jīng)初始化成功了。

此時在瀏覽器上指定IP和端口訪問我們的HTTP服務器,由于我們沒有指定要訪問服務器web根目錄下的那個資源,此時服務器就會默認將web根目錄下的index.html文件進行返回,瀏覽器收到index.html文件后經(jīng)過刷新渲染就顯示出了對應的首頁頁面。

同時服務器端也打印出了本次請求的一些日志信息。如下:

此時通過ps -aL命令可以看到線程池中的線程已經(jīng)被創(chuàng)建好了,其中PID和LWP相同的就是主線程,剩下的就是線程池中處理任務的若干新線程。如下:

錯誤請求測試
錯誤請求測試
如果我們請求的資源服務器并沒有提供,那么服務器就會在獲取請求資源屬性信息時失敗,這時服務器會停止本次請求處理,而直接將web根目錄下的404.html文件返回瀏覽器,瀏覽器收到后經(jīng)過刷新渲染就顯示出了對應的404頁面。

這時在服務器端就能看到一條日志級別為WARNING的日志信息,這條日志信息中說明了客戶端請求的哪一個資源是不存在的。

GET方法上傳數(shù)據(jù)測試
編寫CGI程序
如果用戶請求服務器時上傳了數(shù)據(jù),那么服務器就需要將該數(shù)據(jù)后交給對應的CGI程序進行處理,因此在測試GET方法上傳數(shù)據(jù)之前,我們需要先編寫一個簡單的CGI程序。
首先,CGI程序啟動后需要先獲取父進程傳遞過來的數(shù)據(jù):
- 先通過getenv函數(shù)獲取環(huán)境變量中的請求方法。
- 如果請求方法為GET方法,則繼續(xù)通過getenv函數(shù)獲取父進程傳遞過來的數(shù)據(jù)。
- 如果請求方法為POST方法,則先通過getenv函數(shù)獲取父進程傳遞過來的數(shù)據(jù)的長度,然后再從0號文件描述符中讀取指定長度的數(shù)據(jù)即可。
代碼如下:
bool GetQueryString(std::string& query_string)
{
bool result = false;
std::string method = getenv("METHOD"); //獲取請求方法
if(method == "GET"){ //GET方法通過環(huán)境變量獲取參數(shù)
query_string = getenv("QUERY_STRING");
result = true;
}
else if(method == "POST"){ //POST方法通過管道獲取參數(shù)
int content_length = atoi(getenv("CONTENT_LENGTH"));
//從管道中讀取content_length個參數(shù)
char ch = 0;
while(content_length){
read(0, &ch, 1);
query_string += ch;
content_length--;
}
result = true;
}
else{
//Do Nothing
result = false;
}
return result;
}
CGI程序在獲取到父進程傳遞過來的數(shù)據(jù)后,就可以根據(jù)具體的業(yè)務場景進行數(shù)據(jù)處理了,比如用戶上傳的如果是一個關(guān)鍵字則需要CGI程序做搜索處理。我們這里以演示為目的,認為用戶上傳的是形如a=10&b=20的兩個參數(shù),需要CGI程序進行加減乘除運算。
因此我們的CGI程序要做的就是,先以&為分隔符切割數(shù)據(jù)將兩個操作數(shù)分開,再以=為分隔符切割數(shù)據(jù)分別獲取到兩個操作數(shù)的值,最后對兩個操作數(shù)進行加減乘除運算,并將計算結(jié)果打印到標準輸出即可(標準輸出已經(jīng)被重定向到了管道)。
代碼如下:
bool CutString(std::string& in, const std::string& sep, std::string& out1, std::string& out2)
{
size_t pos = in.find(sep);
if(pos != std::string::npos){
out1 = in.substr(0, pos);
out2 = in.substr(pos + sep.size());
return true;
}
return false;
}
int main()
{
std::string query_string;
GetQueryString(query_string); //獲取參數(shù)
//以&為分隔符將兩個操作數(shù)分開
std::string str1;
std::string str2;
CutString(query_string, "&", str1, str2);
//以=為分隔符分別獲取兩個操作數(shù)的值
std::string name1;
std::string value1;
CutString(str1, "=", name1, value1);
std::string name2;
std::string value2;
CutString(str2, "=", name2, value2);
//處理數(shù)據(jù)
int x = atoi(value1.c_str());
int y = atoi(value2.c_str());
std::cout<<"";
std::cout<<"";
std::cout<<"";
std::cout<<"
"<";
std::cout<<"
"<";
std::cout<<"
"<";
std::cout<<"
"<"; //除0后cgi程序崩潰,屬于異常退出
std::cout<<"";
std::cout<<"";
return 0;
}<<">
<<"><<"><<">說明一下:
- CGI程序輸出的結(jié)果最終會交給瀏覽器,因此CGI程序輸出的最好是一個HTML文件,這樣瀏覽器收到后就可以其渲染到頁面上,讓用戶看起來更美觀。
- 可以看到,使用C/C++以HTML的格式進行輸出是很費勁的,因此這部分操作一般是由Python等語言來完成的,而在此之前對數(shù)據(jù)進行業(yè)務處理的動作一般才用C/C++等語言來完成。
- 在編寫CGI程序時如果要進行調(diào)試,debug內(nèi)容應該通過標準錯誤流進行輸出,因為子進程在被替換成CGI程序之前,已經(jīng)將標準輸出重定向到管道了。
URL上傳數(shù)據(jù)測試
CGI程序編寫編寫完畢并生成可執(zhí)行程序后,將這個可執(zhí)行程序放到web根目錄下,這時在請求服務器時就可以指定請求這個CGI程序,并通過URL上傳參數(shù)讓其進行處理,最終我們就能得到計算結(jié)果。

此外,如果請求CGI程序時指定的第二個操作數(shù)為0,那么CGI程序在進行除法運算時就會崩潰,這時父進程等待子進程后就會發(fā)現(xiàn)子進程是異常退出的,進而設置狀態(tài)碼為INTERNAL_SERVER_ERROR,最終服務器就會構(gòu)建對應的錯誤頁面返回給瀏覽器。

表單上傳數(shù)據(jù)測試
當然,讓用戶通過更改URL的方式來向服務器上傳參數(shù)是不現(xiàn)實的,服務器一般會讓用戶通過表單來上傳參數(shù)。
HTML中的表單用于搜集用戶的輸入,我們可以通過設置表單的method屬性來指定表單提交的方法,通過設置表單的action屬性來指定表單需要提交給服務器上的哪一個CGI程序。
比如現(xiàn)在將服務器的首頁改成以下HTML代碼,指定將表單中的數(shù)據(jù)以GET方法提交給web根目錄下的test_cgi程序:
操作數(shù)1:
操作數(shù)2:
此時我們直接訪問服務器看到的就是一個表單,向表單中輸入兩個操作數(shù)并點擊“計算”后,表單中的數(shù)據(jù)就會以GET方法提交給web根目錄下的test_cgi程序,此時CGI程序進行數(shù)據(jù)計算后同樣將結(jié)果返回給了瀏覽器。

同時在提交表單的一瞬間可以看到,通過表單上傳的數(shù)據(jù)也回顯到了瀏覽器上方的URL中,并且請求的資源也變成了web根目錄下的test_cgi。實際就是我們在點擊“計算”后,瀏覽器檢測到表單method為“get”后,將把表單中數(shù)據(jù)添加到了URL中,并將請求資源路徑替換成了表單action指定的路徑,然后再次向服務器發(fā)起HTTP請求。
理解百度搜索
當我們在百度的搜索框輸入關(guān)鍵字并回車后,可以看到上方的URL發(fā)生了變化,URL中的請求資源路徑為/s,并且URL后面攜帶了很多參數(shù)。

實際這里的/s就可以理解成是百度web根目錄下的一個CGI程序,而URL中攜帶的各種參數(shù)就是交給這個CGI程序做搜索處理的,可以看到攜帶的參數(shù)中有一個名為wd的參數(shù),這個參數(shù)正是用戶的搜索關(guān)鍵字。
POST方法上傳數(shù)據(jù)測試
表單上傳數(shù)據(jù)測試
測試表單通過POST方法上傳數(shù)據(jù)時,只需要將表單中的method屬性改為“post”即可,此時點擊“計算”提交表單時,瀏覽器檢測到表單的提交方法為POST后,就會將表單中的數(shù)據(jù)添加到請求正文中,并將請求資源路徑替換成表單action指定的路徑,然后再次向服務器發(fā)起HTTP請求。

可以看到,由于POST方法是通過請求正文上傳的數(shù)據(jù),因此表單提交后瀏覽器上方的URL中只有請求資源路徑發(fā)生了改變,而并沒有在URL后面添加任何參數(shù)。同時觀察服務器端輸出的日志信息,也可以確認瀏覽器本次的請求方法為POST方法。

項目擴展
當前項目的重點在于HTTP服務器后端的處理邏輯,主要完成的是GET和POST請求方法,以及CGI機制的搭建。如果想對當前項目進行擴展,可以選擇在技術(shù)層面或應用層面進行擴展。
技術(shù)層面的擴展
技術(shù)層面可以選擇進行如下擴展:
- 當前項目編寫的是HTTP1.0版本的服務器,每次連接都只會對一個請求進行處理,當服務器對客戶端的請求處理完畢并收到客戶端的應答后,就會直接斷開連接??梢詫⑵鋽U展為HTTP1.1版本,讓服務器支持長連接,即通過一條連接可以對多個請求進行處理,避免重復建立連接(涉及連接管理)。
- 當前項目雖然在后端接入了線程池,但也只能滿足中小型應用,可以考慮將服務器改寫成epoll版本,讓服務器的IO變得更高效。
- 可以給當前的HTTP服務器新增代理功能,也就是可以替代客戶端去訪問某種服務,然后將訪問結(jié)果再返回給客戶端。
應用層面的擴展
應用層面可以選擇進行如下擴展:
- 基于當前HTTP服務器,搭建在線博客。
- 基于當前HTTP服務器,編寫在線畫圖板。
- 基于當前HTTP服務器,編寫一個搜索引擎。
-
互聯(lián)網(wǎng)
+關(guān)注
關(guān)注
55文章
11350瀏覽量
110491 -
服務器
+關(guān)注
關(guān)注
14文章
10371瀏覽量
91773 -
網(wǎng)絡
+關(guān)注
關(guān)注
14文章
8340瀏覽量
95600 -
編程
+關(guān)注
關(guān)注
90文章
3724瀏覽量
97458 -
HTTP
+關(guān)注
關(guān)注
0文章
538瀏覽量
35571
發(fā)布評論請先 登錄
求助,能否實現(xiàn)PPP撥號功能+構(gòu)建HTTP服務器?
WebNet軟件包實現(xiàn)HTTP服務器的基本原理是什么?
HTTP OTA webclient獲取不到服務器如何處理?
如何基于HTTP Web服務器示例實現(xiàn)TLS HTTPS服務器?
HTTP服務器fsdata_custom.c項目問題求解
如何實現(xiàn)ESP32上運行運行HTTP服務器?
如何辨別Web服務器,應用程序服務器,HTTP服務器
如何用Python 實現(xiàn) HTTP 和 FTP 服務器
利用iMCU7100EVB實現(xiàn)HTTP服務器(一)
如何正確的理解使用WEB服務器和應用程序服務器及HTTP服務器
三種常見的服務器詳細介紹
基于LwIP的HTTP服務器設計
如何利用AWFlow搭建HTTP服務器
HTTP服務器項目實現(xiàn)介紹
評論