整了一個後臺服務,香!

大家好,我是盼盼!

HTTP 服務是重中之重,今天分享一下 一個 HTTP 服務的實現。

項目介紹

本項目實現的是一個 HTTP 服務器,項目中將會通過基本的網絡套接字讀取客戶端發來的 HTTP 請求並進行分析,最終構建 HTTP 響應並返回給客戶端。

HTTP 在網絡應用層中的地位是不可撼動的,無論是移動端還是 PC 端瀏覽器,HTTP 無疑是打開互聯網應用窗口的重要協議。

該項目將會把 HTTP 中最核心的模塊抽取出來,採用 CS 模型實現一個小型的 HTTP 服務器,目的在於理解 HTTP 協議的處理過程。

該項目主要涉及 C/C++、HTTP 協議、網絡套接字編程、CGI、單例模式、多線程、線程池等方面的技術。

網絡協議棧介紹

協議分層

網絡協議棧的分層情況如下:

網絡協議棧中各層的功能如下:

  1. 應用層:根據特定的通信目的,對數據進行分析處理,以達到某種業務性的目的。

  2. 傳輸層:處理傳輸時遇到的問題,主要是保證數據傳輸的可靠性。

  3. 網絡層:完成數據的轉發,解決數據去哪裏的問題。

  4. 鏈路層:負責數據真正的發生過程。

數據的封裝與分用

數據封裝與分用的過程如下:

也就是說,發送端在發生數據前,該數據需要先自頂向下貫穿網絡協議棧完成數據的封裝,在這個過程中,每一層協議都會爲該數據添加上對應的報頭信息。接收端在收到數據後,該數據需要先自底向上貫穿網絡協議棧完成數據的解包和分用,在這個過程中,每一層協議都會將對應的報頭信息提取出來。

而本項目要做的就是,在接收到客戶端發來的 HTTP 請求後,將 HTTP 的報頭信息提取出來,然後對數據進行分析處理,最終將處理結果添加上 HTTP 報頭再發送給客戶端。

需要注意的是,該項目中我們所處的位置是應用層,因此我們讀取的 HTTP 請求實際是從傳輸層讀取上來的,而我們發送的 HTTP 響應實際也只是交給了傳輸層,數據真正的發送還得靠網絡協議棧中的下三層來完成,這裏直接說 “接收到客戶端的 HTTP 請求” 以及“發送 HTTP 響應給客戶端”,只是爲了方便大家理解,此外,同層協議之間本身也是可以理解成是在直接通信的。

HTTP 相關知識介紹

HTTP 的特點

HTTP 的五大特點如下:

  1. 客戶端服務器模式(CS,BS):在一條通信線路上必定有一端是客戶端,另一端是服務器端,請求從客戶端發出,服務器響應請求並返回。

  2. 簡單快速:客戶端向服務器請求服務時,只需傳送請求方法和請求資源路徑,不需要發送額外過多的數據,並且由於 HTTP 協議結構較爲簡單,使得 HTTP 服務器的程序規模小,因此通信速度很快。

  3. 靈活:HTTP 協議對數據對象沒有要求,允許傳輸任意類型的數據對象,對於正在傳輸的數據類型,HTTP 協議將通過報頭中的 Content-Type 屬性加以標記。

  4. 無連接:每次連接都只會對一個請求進行處理,當服務器對客戶端的請求處理完畢並收到客戶端的應答後,就會直接斷開連接。HTTP 協議採用這種方式可以大大節省傳輸時間,提高傳輸效率。

  5. 無狀態:HTTP 協議自身不對請求和響應之間的通信狀態進行保存,每個請求都是獨立的,這是爲了讓 HTTP 能更快地處理大量事務,確保協議的可伸縮性而特意設計的。

說明一下:

  1. 隨着 HTTP 的普及,文檔中包含大量圖片的情況多了起來,每次請求都要斷開連接,無疑增加了通信量的開銷,因此 HTTP1.1 支持了長連接 Keey-Alive,就是任意一端只要沒有明確提出斷開連接,則保持連接狀態。(當前項目實現的是 1.0 版本的 HTTP 服務器,因此不涉及長連接)

  2. HTTP 無狀態的特點無疑可以減少服務器內存資源的消耗,但是問題也是顯而易見的。比如某個網站需要登錄後才能訪問,由於無狀態的特點,那麼每次跳轉頁面的時候都需要重新登錄。爲了解決無狀態的問題,於是引入了 Cookie 技術,通過在請求和響應報文中寫入 Cookie 信息來控制客戶端的狀態,同時爲了保護用戶數據的安全,又引入了 Session 技術,因此現在主流的 HTTP 服務器都是通過 Cookie+Session 的方式來控制客戶端的狀態的。

URL 格式

URL(Uniform Resource Lacator)叫做統一資源定位符,也就是我們通常所說的網址,是因特網的萬維網服務程序上用於指定信息位置的表示方法。

一個 URL 大致由如下幾部分構成:簡單說明:

  1. http:// 表示的是協議名稱,表示請求時需要使用的協議,通常使用的是 HTTP 協議或安全協議 HTTPS。

  2. user:pass 表示的是登錄認證信息,包括登錄用戶的用戶名和密碼。(可省略)

  3. www.example.jp 表示的是服務器地址,通常以域名的形式表示。

  4. 80 表示的是服務器的端口號。(可省略)

  5. /dir/index.html 表示的是要訪問的資源所在的路徑(/ 表示的是 web 根目錄)。

  6. uid=1 表示的是請求時通過 URL 傳遞的參數,這些參數以鍵值對的形式通過 & 符號分隔開。(可省略)

  7. ch1 表示的是片段標識符,是對資源的部分補充。(可省略) 注意:

  8. 如果訪問服務器時沒有指定要訪問的資源路徑,那麼瀏覽器會自動幫我們添加 /,但此時仍然沒有指明要訪問 web 根目錄下的哪一個資源文件,這時默認訪問的是目標服務的首頁。

  9. 大部分 URL 中的端口號都是省略的,因爲常見協議對應的端口號都是固定的,比如 HTTP、HTTPS 和 SSH 對應的端口號分別是 80、443 和 22,在使用這些常見協議時不必指明協議對應的端口號,瀏覽器會自動幫我們進行填充。

URI、URL、URN

URI、URL、URN 的定義如下:

  1. URI(Uniform Resource Indentifier)統一資源標識符:用來唯一標識資源。

  2. URL(Uniform Resource Locator)統一資源定位符:用來定位唯一的資源。

  3. URN(Uniform Resource Name)統一資源名稱:通過名字來標識資源,比如 mailto:java-net@java.sun.com。URI、URL、URN 三者的關係 URL 是 URI 的一種,URL 不僅能唯一標識資源,還定義了該如何訪問或定位該資源,URN 也是 URI 的一種,URN 通過名字來標識資源,因此 URL 和 URN 都是 URI 的子集。

URI、URL、URN 三者的關係如下:

URI 有絕對和相對之分:

  1. 絕對的 URI:對標識符出現的環境沒有依賴,比如 URL 就是一種絕對的 URI,同一個 URL 無論出現在什麼地方都能唯一標識同一個資源。

  2. 相對的 URI:對標識符出現的環境有依賴,比如 HTTP 請求行中的請求資源路徑就是一種相對的 URI,這個資源路徑出現在不同的主機上標識的就是不同的資源。

HTTP 的協議格式

HTTP 請求協議格式如下:

HTTP 請求由以下四部分組成:

  1. 請求行:[請求方法] + [URI] + [HTTP 版本]。

  2. 請求報頭:請求的屬性,這些屬性都是以 key: value 的形式按行陳列的。

  3. 空行:遇到空行表示請求報頭結束。

  4. 請求正文:請求正文允許爲空字符串,如果請求正文存在,則在請求報頭中會有一個 Content-Length 屬性來標識請求正文的長度。HTTP 響應協議格式如下:HTTP 響應由以下四部分組成:

  5. 狀態行:[HTTP 版本] + [狀態碼] + [狀態碼描述]。

  6. 響應報頭:響應的屬性,這些屬性都是以 key: value 的形式按行陳列的。

  7. 空行:遇到空行表示響應報頭結束。

  8. 響應正文:響應正文允許爲空字符串,如果響應正文存在,則在響應報頭中會有一個 Content-Length 屬性來標識響應正文的長度。

HTTP 的請求方法

HTTP 常見的請求方法如下:GET 方法和 POST 方法
HTTP 的請求方法中最常用的就是 GET 方法和 POST 方法,其中 GET 方法一般用於獲取某種資源信息,而 POST 方法一般用於將數據上傳給服務器,但實際 GET 方法也可以用來上傳數據,比如百度搜索框中的數據就是使用 GET 方法提交的。

GET 方法和 POST 方法都可以帶參,其中 GET 方法通過 URL 傳參,POST 方法通過請求正文傳參。由於 URL 的長度是有限制的,因此 GET 方法攜帶的參數不能太長,而 POST 方法通過請求正文傳參,一般參數長度沒有限制。

HTTP 的狀態碼

HTTP 常見的 Header

  1. Content-Type:數據類型(text/html 等)。

  2. Content-Length:正文的長度。

  3. Host:客戶端告知服務器,所請求的資源是在哪個主機的哪個端口上。

  4. User-Agent:聲明用戶的操作系統和瀏覽器的版本信息。

  5. Referer:當前頁面是哪個頁面跳轉過來的。

  6. Location:搭配 3XX 狀態碼使用,告訴客戶端接下來要去哪裏訪問。

  7. Cookie:用戶在客戶端存儲少量信息,通常用於實現會話(session)的功能。

CGI 機制介紹

CGI(Common Gateway Interface,通用網關接口)是一種重要的互聯網技術,可以讓一個客戶端,從網頁瀏覽器向執行在網絡服務器上的程序請求數據。CGI 描述了服務器和請求處理程序之間傳輸數據的一種標準。

實際我們在進行網絡請求時,無非就兩種情況:

  1. 瀏覽器想從服務器上拿下來某種資源,比如打開網頁、下載等。

  2. 瀏覽器想將自己的數據上傳至服務器,比如上傳視頻、登錄、註冊等。

通常從服務器上獲取資源對應的請求方法就是 GET 方法,而將數據上傳至服務器對應的請求方法就是 POST 方法,但實際 GET 方法有時也會用於上傳數據,只不過 POST 方法是通過請求正文傳參的,而 GET 方法是通過 URL 傳參的。

而用戶將自己的數據上傳至服務器並不僅僅是爲了上傳,用戶上傳數據的目的是爲了讓 HTTP 或相關程序對該數據進行處理,比如用戶提交的是搜索關鍵字,那麼服務器就需要在後端進行搜索,然後將搜索結果返回給瀏覽器,再由瀏覽器對 HTML 文件進行渲染刷新展示給用戶。

但實際對數據的處理與 HTTP 的關係並不大,而是取決於上層具體的業務場景的,因此 HTTP 不對這些數據做處理。但 HTTP 提供了 CGI 機制,上層可以在服務器中部署若干個 CGI 程序,這些 CGI 程序可以用任何程序設計語言編寫,當 HTTP 獲取到數據後會將其提交給對應 CGI 程序進行處理,然後再用 CGI 程序的處理結果構建 HTTP 響應返回給瀏覽器。

其中 HTTP 獲取到數據後,如何調用目標 CGI 程序、如何傳遞數據給 CGI 程序、如何拿到 CGI 程序的處理結果,這些都屬於 CGI 機制的通信細節,而本項目就是要實現一個 HTTP 服務器,因此 CGI 的所有交互細節都需要由我們來完成。

只要用戶請求服務器時上傳了數據,那麼服務器就需要使用 CGI 模式對用戶上傳的數據進行處理,而如果用戶只是單純的想請求服務器上的某個資源文件則不需要使用 CGI 模式,此時直接將用戶請求的資源文件返回給用戶即可。

此外,如果用戶請求的是服務器上的一個可執行程序,說明用戶想讓服務器運行這個可執行程序,此時也需要使用 CGI 模式。

CGI 機制的實現步驟

一、創建子進程進行程序替換

服務器獲取到新連接後一般會創建一個新線程爲其提供服務,而要執行 CGI 程序一定需要調用 exec 系列函數進行進程程序替換,但服務器創建的新線程與服務器進程使用的是同一個進程地址空間,如果直接讓新線程調用 exec 系列函數進行進程程序替換,此時服務器進程的代碼和數據就會直接被替換掉,相當於 HTTP 服務器在執行一次 CGI 程序後就直接退出了,這肯定是不合理的。因此新線程需要先調用 fork 函數創建子進程,然後讓子進程調用 exec 系列函數進行進程程序替換。

二、完成管道通信信道的建立

調用 CGI 程序的目的是爲了讓其進行數據處理,因此我們需要通過某種方式將數據交給 CGI 程序,並且還要能夠獲取到 CGI 程序處理數據後的結果,也就是需要進行進程間通信。因爲這裏的服務器進程和 CGI 進程是父子進程,因此優先選擇使用匿名管道。

由於父進程不僅需要將數據交給子進程,還需要從子進程那裏獲取數據處理的結果,而管道是半雙工通信的,爲了實現雙向通信於是需要藉助兩個匿名管道,因此在創建調用 fork 子進程之前需要先創建兩個匿名管道,在創建子進程後還需要父子進程分別關閉兩個管道對應的讀寫端。

三、完成重定向相關的設置

創建用於父子進程間通信的兩個匿名管道時,父子進程都是各自用兩個變量來記錄管道對應讀寫端的文件描述符的,但是對於子進程來說,當子進程調用 exec 系列函數進行程序替換後,子進程的代碼和數據就被替換成了目標 CGI 程序的代碼和數據,這也就意味着被替換後的 CGI 程序無法得知管道對應的讀寫端,這樣父子進程之間也就無法進行通信了。

需要注意的是,進程程序替換隻替換對應進程的代碼和數據,而對於進程的進程控制塊、頁表、打開的文件等內核數據結構是不做任何替換的。因此子進程進行進程程序替換後,底層創建的兩個匿名管道仍然存在,只不過被替換後的 CGI 程序不知道這兩個管道對應的文件描述符罷了。

這時我們可以做一個約定:被替換後的 CGI 程序,從標準輸入讀取數據等價於從管道讀取數據,向標準輸出寫入數據等價於向管道寫入數據。這樣一來,所有的 CGI 程序都不需要得知管道對應的文件描述符了,當需要讀取數據時直接從標準輸入中進行讀取,而數據處理的結果就直接寫入標準輸出就行了。

當然,這個約定並不是你說有就有的,要實現這個約定需要在子進程被替換之前進行重定向,將 0 號文件描述符重定向到對應管道的讀端,將 1 號文件描述符重定向到對應管道的寫端。

四、父子進程交付數據   這時父子進程已經能夠通過兩個匿名管道進行通信了,接下來就應該討論父進程如何將數據交給 CGI 程序,以及 CGI 程序如何將數據處理結果交給父進程了。

父進程將數據交給 CGI 程序:

  1. 如果請求方法爲 GET 方法,那麼用戶是通過 URL 傳遞參數的,此時可以在子進程進行進程程序替換之前,通過 putenv 函數將參數導入環境變量,由於環境變量也不受進程程序替換的影響,因此被替換後的 CGI 程序就可以通過 getenv 函數來獲取對應的參數。

  2. 如果請求方法爲 POST 方法,那麼用戶是通過請求正文傳參的,此時父進程直接將請求正文中的數據寫入管道傳遞給 CGI 程序即可,但是爲了讓 CGI 程序知道應該從管道讀取多少個參數,父進程還需要通過 putenv 函數將請求正文的長度導入環境變量。

說明一下:請求正文長度、URL 傳遞的參數以及請求方法都比較短,通過寫入管道來傳遞會導致效率降低,因此選擇通過導入環境變量的方式來傳遞。

也就是說,使用 CGI 模式時如果請求方法爲 POST 方法,那麼 CGI 程序需要從管道讀取父進程傳遞過來的數據,如果請求方法爲 GET 方法,那麼 CGI 程序需要從環境變量中獲取父進程傳遞過來的數據。

但被替換後的 CGI 程序實際並不知道本次 HTTP 請求所對應的請求方法,因此在子進程在進行進程程序替換之前,還需要通過 putenv 函數將本次 HTTP 請求所對應的請求方法也導入環境變量。因此 CGI 程序啓動後,首先需要先通過環境變量得知本次 HTTP 請求所對應的請求方法,然後再根據請求方法對應從管道或環境變量中獲取父進程傳遞過來的數據。

CGI 程序讀取到父進程傳遞過來的數據後,就可以進行對應的數據處理了,最終將數據處理結果寫入到管道中,此時父進程就可以從管道中讀取 CGI 程序的處理結果了。

CGI 機制的意義

CGI 機制的處理流程如下:

處理 HTTP 請求的步驟如下:

  1. 判斷請求方法是 GET 方法還是 POST 方法,如果是 GET 方法帶參或 POST 方法則進行 CGI 處理,如果是 GET 方法不帶參則進行非 CGI 處理。

  2. 非 CGI 處理就是直接根據用戶請求的資源構建 HTTP 響應返回給瀏覽器。

  3. CGI 處理就是通過創建子進程進行程序替換的方式來調用 CGI 程序,通過創建匿名管道、重定向、導入環境變量的方式來與 CGI 程序進行數據通信,最終根據 CGI 程序的處理結果構建 HTTP 響應返回給瀏覽器。

  4. CGI 機制就是讓服務器將獲取到的數據交給對應的 CGI 程序進行處理,然後將 CGI 程序的處理結果返回給客戶端,這顯然讓服務器邏輯和業務邏輯進行了解耦,讓服務器和業務程序可以各司其職。

  5. CGI 機制使得瀏覽器輸入的數據最終交給了 CGI 程序,而 CGI 程序輸出的結果最終交給了瀏覽器。這也就意味着 CGI 程序的開發者,可以完全忽略中間服務器的處理邏輯,相當於 CGI 程序從標準輸入就能讀取到瀏覽器輸入的內容,CGI 程序寫入標準輸出的數據最終就能輸出到瀏覽器。

日誌編寫

服務器在運作時會產生一些日誌,這些日誌會記錄下服務器運行過程中產生的一些事件。本項目中的日誌格式如下:

日誌說明:

  1. 日誌級別:分爲四個等級,從低到高依次是 INFO、WARNING、ERROR、FATAL。

  2. 時間戳:事件產生的時間。

  3. 日誌信息:事件產生的日誌信息。

  4. 錯誤文件名稱:事件在哪一個文件產生。

  5. 行數:事件在對應文件的哪一行產生。日誌級別說明:

  6. INFO:表示正常的日誌輸出,一切按預期運行。

  7. WARNING:表示警告,該事件不影響服務器運行,但存在風險。

  8. ERROR:表示發生了某種錯誤,但該事件不影響服務器繼續運行。

  9. FATAL:表示發生了致命的錯誤,該事件將導致服務器停止運行。

日誌函數編寫   我們可以針對日誌編寫一個輸出日誌的 Log 函數,該函數的參數就包括日誌級別、日誌信息、錯誤文件名稱、錯誤的行數。如下:

void Log(std::string level, std::string message, std::string file_name, int line)
{
  std::cout<<"["<<level<<"]["<<time(nullptr)<<"]["<<message<<"]["<<file_name<<"]["<<line<<"]"<<std::endl;
}

說明一下:調用 time 函數時傳入 nullptr 即可獲取當前的時間戳,因此調用 Log 函數時不必傳入時間戳。

文件名稱和行數的問題

通過 C 語言中的預定義符號__FILE__和__LINE__,分別可以獲取當前文件的名稱和當前的行數,但最好在調用 Log 函數時不用調用者顯示的傳入__FILE__和__LINE__,因爲每次調用 Log 函數時傳入的這兩個參數都是固定的。

需要注意的是,不能將__FILE__和__LINE__設置爲參數的缺省值,因爲這樣每次獲取到的都是 Log 函數所在的文件名稱和所在的行數。而宏可以在預處理期間將代碼插入到目標地點,因此我們可以定義如下宏:

#define LOG(level, message) Log(level, message, __FILE__, __LINE__)

後續需要打印日誌的時候就直接調用 LOG,調用時只需要傳入日誌級別和日誌信息,在預處理期間__FILE__和__LINE__就會被插入到目標地點,這時就能獲取到日誌產生的文件名稱和對應的行數了。

日誌級別傳入問題   我們後續調用 LOG 傳入日誌級別時,肯定希望以 INFO、WARNING 這樣的方式傳入,而不是以 "INFO"、"WARNING" 這樣的形式傳入,這時我們可以將這四個日誌級別定義爲宏,然後通過 #將宏參數 level 變成對應的字符串。如下:

#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4

#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)

此時以 INFO、WARNING 的方式傳入 LOG 的宏參數,就會被轉換成對應的字符串傳遞給 Log 函數的 level 參數,後續我們就可以以如下方式輸出日誌了:

LOG(INFO, "This is a demo"); //LOG使用示例

套接字相關代碼編寫

們可以將套接字相關的代碼封裝到 TcpServer 類中,在初始化 TcpServer 對象時完成套接字的創建、綁定和監聽動作,並向外提供一個 Sock 接口用於獲取監聽套接字。

此外,可以將 TcpServer 設置成單例模式:

將 TcpServer 類的構造函數設置爲私有,並將拷貝構造和拷貝賦值函數設置爲私有或刪除,防止外部創建或拷貝對象。提供一個指向單例對象的 static 指針,並在類外將其初始化爲 nullptr。提供一個全局訪問點獲取單例對象,在單例對象第一次被獲取的時候就創建這個單例對象並進行初始化。代碼如下:

#define BACKLOG 5

//TCP服務器
class TcpServer{
    private:
        int _port;              //端口號
        int _listen_sock;       //監聽套接字
        static TcpServer* _svr; //指向單例對象的static指針
    private:
        //構造函數私有
        TcpServer(int port)
            :_port(port)
            ,_listen_sock(-1)
        {}
        //將拷貝構造函數和拷貝賦值函數私有或刪除(防拷貝)
        TcpServer(const TcpServer&)=delete;
        TcpServer* operator=(const TcpServer&)=delete;
    public:
        //獲取單例對象
        static TcpServer* GetInstance(int port)
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定義靜態的互斥鎖
            if(_svr == nullptr){
                pthread_mutex_lock(&mtx); //加鎖
                if(_svr == nullptr){
                    //創建單例TCP服務器對象並初始化
                    _svr = new TcpServer(port);
                    _svr->InitServer();
                }
                pthread_mutex_unlock(&mtx); //解鎖
            }
            return _svr; //返回單例對象
        }
        //初始化服務器
        void InitServer()
        {
            Socket(); //創建套接字
            Bind();   //綁定
            Listen(); //監聽
            LOG(INFO, "tcp_server init ... success");
        }
        //創建套接字
        void Socket()
        {
            _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
            if(_listen_sock < 0){ //創建套接字失敗
                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");
        }
        //監聽
        void Listen()
        {
            if(listen(_listen_sock, BACKLOG) < 0){ //監聽失敗
                LOG(FATAL, "listen error!");
                exit(3);
            }
            LOG(INFO, "listen socket ... success");
        }
        //獲取監聽套接字
        int Sock()
        {
            return _listen_sock;
        }
        ~TcpServer()
        {
            if(_listen_sock >= 0){ //關閉監聽套接字
                close(_listen_sock);
            }
        }
};
//單例對象指針初始化爲nullptr
TcpServer* TcpServer::_svr = nullptr;

說明一下:

  1. 如果使用的是雲服務器,那麼在設置服務器的 IP 地址時,不需要顯式綁定 IP 地址,直接將 IP 地址設置爲 INADDR_ANY 即可,此時服務器就可以從本地任何一張網卡當中讀取數據。此外,由於 INADDR_ANY 本質就是 0,因此在設置時不需要進行網絡字節序列的轉換。

  2. 在第一次調用 GetInstance 獲取單例對象時需要創建單例對象,這時需要定義一個鎖來保證線程安全,代碼中以 PTHREAD_MUTEX_INITIALIZER 的方式定義的靜態的鎖是不需要釋放的,同時爲了保證後續調用 GetInstance 獲取單例對象時不會頻繁的加鎖解鎖,因此代碼中以雙檢查的方式進行加鎖。

HTTP 服務器主體邏輯

我們可以將 HTTP 服務器封裝成一個 HttpServer 類,在構造 HttpServer 對象時傳入一個端口號,之後就可以調用 Loop 讓服務器運行起來了。服務器運行起來後要做的就是,先獲取單例對象 TcpServer 中的監聽套接字,然後不斷從監聽套接字中獲取新連接,每當獲取到一個新連接後就創建一個新線程爲該連接提供服務。

代碼如下:

#define PORT 8081

//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(); //獲取監聽套接字
            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; //獲取失敗,繼續獲取
                }

                //打印客戶端相關信息
                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)+"]");
                
                //創建新線程處理新連接發起的HTTP請求
                int* p = new int(sock);
                pthread_t tid;
                pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
                pthread_detach(tid); //線程分離
            }
        }
        ~HttpServer()
        {}
};

說明一下:

  1. 服務器需要將新連接對應的套接字作爲參數傳遞給新線程,爲了避免該套接字在新線程讀取之前被下一次獲取到的套接字覆蓋,因此在傳遞套接字時最好重新 new 一塊空間來存儲套接字的值。

  2. 新線程創建後可以將新線程分離,分離後主線程繼續獲取新連接,而新線程則處理新連接發來的 HTTP 請求,代碼中的 HandlerRequest 函數就是新線程處理新連接時需要執行的回調函數。

運行服務器時要求指定服務器的端口號,我們用這個端口號創建一個 HttpServer 對象,然後調用 Loop 函數運行服務器,此時服務器就會不斷獲取新連接並創建新線程來處理連接。

static void Usage(std::string proc)
{
    std::cout<<"Usage:\n\t"<<proc<<" port"<<std::endl;
}
int main(int argc, char* argv[])
{
    if(argc != 2){
        Usage(argv[0]);
        exit(4);
    }
    int port = atoi(argv[1]); //端口號
    std::shared_ptr<HttpServer> svr(new HttpServer(port)); //創建HTTP服務器對象
    svr->Loop(); //啓動服務器
    return 0;
}

HTTP 請求結構設計

我們可以將 HTTP 請求封裝成一個類,這個類當中包括 HTTP 請求的內容、HTTP 請求的解析結果以及是否需要使用 CGI 模式的標誌位。後續處理請求時就可以定義一個 HTTP 請求類,讀取到的 HTTP 請求的數據就存儲在這個類當中,解析 HTTP 請求後得到的數據也存儲在這個類當中。

代碼如下:

//HTTP請求
class HttpRequest{
    public:
        //HTTP請求內容
        std::string _request_line;                //請求行
        std::vector<std::string> _request_header; //請求報頭
        std::string _blank;                       //空行
        std::string _request_body;                //請求正文

        //解析結果
        std::string _method;       //請求方法
        std::string _uri;          //URI
        std::string _version;      //版本號
        std::unordered_map<std::string, std::string> _header_kv; //請求報頭中的鍵值對
        int _content_length;       //正文長度
        std::string _path;         //請求資源的路徑
        std::string _query_string; //uri中攜帶的參數

        //CGI相關
        bool _cgi; //是否需要使用CGI模式
    public:
        HttpRequest()
            :_content_length(0) //默認請求正文長度爲0
            ,_cgi(false)        //默認不使用CGI模式
        {}
        ~HttpRequest()
        {}
};

HTTP 響應結構設計

HTTP 響應也可以封裝成一個類,這個類當中包括 HTTP 響應的內容以及構建 HTTP 響應所需要的數據。後續構建響應時就可以定義一個 HTTP 響應類,構建響應需要使用的數據就存儲在這個類當中,構建後得到的響應內容也存儲在這個類當中。

代碼如下:

//HTTP響應
class HttpResponse{
    public:
        //HTTP響應內容
        std::string _status_line;                  //狀態行
        std::vector<std::string> _response_header; //響應報頭
        std::string _blank;                        //空行
        std::string _response_body;                //響應正文(CGI相關)

        //所需數據
        int _status_code;    //狀態碼
        int _fd;             //響應文件的fd  (非CGI相關)
        int _size;           //響應文件的大小(非CGI相關)
        std::string _suffix; //響應文件的後綴(非CGI相關)
    public:
        HttpResponse()
            :_blank(LINE_END) //設置空行
            ,_status_code(OK) //狀態碼默認爲200
            ,_fd(-1)          //響應文件的fd初始化爲-1
            ,_size(0)         //響應文件的大小默認爲0
        {}
        ~HttpResponse()
        {}
};

來源:blog.csdn.net/chenlong_cxy/article/details/127906255

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fRPMoiPg3OFEgoIxfTlpEQ