目錄
設計思路
類的設計
解碼過程
模塊的實現
私有接口
請求函數
解析函數
公有接口
疑惑點
設計思路
記錄每一次請求處理的進度,便于下一次處理。
上下文模塊是Http協議模塊中最重要的一個模塊,他需要記錄每一次請求處理的進度,需要保存一個HttpRequest對象,后續關于這個連接的http的處理的信息全部都是在這個上下文中保存。
那既然是記錄,肯定就要讓它知道當前是在哪個進度了,所以我們就用狀態碼去表示
//處理狀態
enum HttpRecvStatu{RECV_ERR, //接收錯誤RECV_LINE, //接收請求行RECV_HEAD, //接收頭部RECV_BODY, //接收正文RECV_OVER //接收完畢
};
注意:這些枚舉值通常表示當前正在處理的狀態,而不是已經完成的狀態。
同時,由于收到的http請求報文可能是會出錯的,而出錯的話,我們是不會將這個報文進行業務的處理的,而是直接返回一個請求錯誤的狀態碼的報文,那么我們的上下文當中不可避免的還需要保存一個變量用來保存狀態碼。
還需要有一個 HttpRequest 對象來存儲從客戶端請求中解析出的各種信息。
具體來說,HttpRequest 對象通常會存儲以下請求要素:
- 請求方法 (Method):如 GET、POST、PUT、DELETE 等
- 請求的 URL 路徑
- HTTP 版本 (如 HTTP/1.1)
- 請求頭 (Headers):如 Content-Type、User-Agent、Cookie 等
- 請求參數 (如 URL 中的查詢參數)
- 請求體 (Body):POST 請求中包含的數據
- 其他請求相關的元數據
類的設計
既然它需要那么多狀態,那說明它肯定是要處理這些狀態的,也就是需要處理請求行,處理頭部,處理正文,處理錯誤。那這些報文從哪里來呢?肯定是需要從緩沖區接收過來的,當然接收過來之后,我們還需要把緩沖區的數據的請求行段,請求頭部段,請求正文段,分離出來,以便后續我們使用。接下來我們先看一個解碼的過程,來加深理解一下流程
解碼過程
假設我們收到了以下 HTTP 請求:
GET /my%20documents/report.pdf?search=machine+learning&year=2023 HTTP/1.1
第一步:解析請求行
正則表達式匹配結果:
- matches[1] = "GET"(請求方法)
- matches[2] = "/my%20documents/report.pdf"(路徑部分)
- matches[3] = "search=machine+learning&year=2023"(查詢字符串部分)
- matches[4] = "HTTP/1.1"(HTTP版本)
第二步:解碼路徑部分
我們對 matches[2] 使用 UrlDecode(matches[2], false)
進行解碼:
- 參數 false 表示不將加號(+)轉換為空格
- 將 %20 解碼為空格字符
- 輸入:"/my%20documents/report.pdf"
- 輸出:"/my documents/report.pdf"
第三步:處理查詢字符串
- 首先拆分查詢字符串(matches[3]):
- 輸入:"search=machine+learning&year=2023"
- 使用
&
作為分隔符拆分 - 結果:["search=machine+learning", "year=2023"]
- 對每個參數進行處理: 對于第一個參數 "search=machine+learning":
- 找到等號(=)位置:pos = 6
- 提取鍵:str.substr(0, pos) = "search"
- 提取值:str.substr(pos + 1) = "machine+learning"
- 對鍵進行解碼:UrlDecode("search", true) = "search"(無需解碼)
- 對值進行解碼:UrlDecode("machine+learning", true) = "machine learning" (注意這里使用 true 參數,將加號轉換為空格)
- 將鍵值對保存到請求對象:_request.setParam("search", "machine learning")
- 找到等號(=)位置:pos = 4
- 提取鍵:str.substr(0, pos) = "year"
- 提取值:str.substr(pos + 1) = "2023"
- 對鍵進行解碼:UrlDecode("year", true) = "year"(無需解碼)
- 對值進行解碼:UrlDecode("2023", true) = "2023"(無需解碼)
- 將鍵值對保存到請求對象:_request.setParam("year", "2023")
結果
請求對象中現在包含以下數據:
- 路徑: "/my documents/report.pdf"
- 查詢參數:
- "search" -> "machine learning"
- "year" -> "2023"
所以我們的類聲明如下?
#define MAX_LINE 8192 // HTTP請求行最大長度// HTTP請求解析上下文類
class HttpContext
{
private:HttpParseStatu _parse_status; // 當前解析狀態int _status_code; // 狀態碼HttpRequest _request; // 存儲解析出的HTTP請求信息private:bool RecvLine(Buffer *buf); // 接收請求行bool RecvHead(Buffer* buf); // 接收請求頭部bool RecvBody(); // 接收請求正文bool ParseLine(const string &line); // 解析請求行bool ParsesHead(string &line); // 解析請求頭部public:HttpContext(); // 構造函數void ReSet(); // 重置解析狀態int StatusCode(); // 獲取HTTP狀態碼HttpParseStatu ParseStatus(); // 獲取當前解析狀態HttpRequest& Request(); // 獲取解析完成的HTTP請求void RecvHttpRequest(Buffer *buf); // 接收處理HTTP請求數據
};
模塊的實現
對于私有模塊,也就是接收緩沖區的數據,然后把請求行,請求頭部,請求正文獲取到并且解析出來,然后放入到HttpRequest中存儲,以供應用層進行調用
私有接口
請求函數
對于請求函數,基本上就是大差不差,先判斷狀態是否匹配,然后獲取緩沖區數據的一行數據,進行判斷是否是完整的數據,然后判斷異常,異常有兩種,第一種是沒有拿到請求數據(請求行,請求頭部,請求正文),但是緩沖區的數據已經超過最大值了,但還不是完整的數據,這說明數據是錯的,那么我們就不處理,直接修改狀態成錯誤,設置錯誤狀態碼就行了。第二種就是拿到了請求數據,但是數據也是巨大,這個時候我們也是不處理,修改狀態為錯誤。設置錯誤狀態碼。如果合法了就開始解析請求數據,然后更新數據。這里解析請求數據是不一樣的,等會重點講解析的函數
bool RecvLine(Buffer *buf) // 接收請求行{if (_parse_status != RECV_HTTP_LINE){return false;}string line = buf->GetLineAndPop(); // 根據 HTTP 協議規范,HTTP 請求的結構是固定的,第一行必須是請求行if (line.size() == 0) // 說明出問題了,去緩沖區找問題{if (buf->ReadAbleSize() > MAX_LINE) // 說明此時緩沖區有數據,但是數據太大了還沒有結束{_parse_status = RECV_HTTP_ERR;_status_code = 414; // URI TOO LONGreturn false;}// 否則就說明緩沖區的請求行太少,還沒發完,再等等return true;}if (line.size() > MAX_LINE) // 說明雖然拿到了請求行,但是肯定是錯誤的,請求行的數據哪能那么多{_parse_status = RECV_HTTP_ERR;_status_code = 414; // URI TOO LONGreturn false;}//走到這就說明請求行合法了,開始處理bool ret = ParseLine(line);if(ret == false){return false;}//請求行狀態結束,更新下一個狀態_parse_status = RECV_HTTP_HEAD;return true;}bool RecvHead(Buffer* buf) // 接收請求報頭{if (_parse_status != RECV_HTTP_HEAD) //進行判斷是否是請求報頭的狀態了{return false;}//走到這就說明第一行的請求行已經被取走了,現在緩沖區的第一行就是請求報頭了while(1)//因為報頭的格式每行都是xxxx\n\r,一直到空格行才算結束{string line = buf->GetLineAndPop();if (line.size() == 0) // 說明出問題了,去緩沖區找問題{if (buf->ReadAbleSize() > MAX_LINE) // 說明此時緩沖區有數據,但是數據太大了還沒有結束{_parse_status = RECV_HTTP_ERR;_status_code = 414; // URI TOO LONGreturn false;}// 否則就說明緩沖區的請求行太少,還沒發完,再等等return true;}if (line.size() > MAX_LINE) // 說明雖然拿到了請求行,但是肯定是錯誤的,請求行的數據哪能那么多{_parse_status = RECV_HTTP_ERR;_status_code = 414; // URI TOO LONGreturn false;}//走到這就說明拿到了正常的數據if(line == "\n" || line == "\r\n") //如果這一行是報頭的結束標志就要退出循環{break;}int ret = ParsesHead(line); //因為每一行都不一樣,所以每取一行就要進行保存if(ret == false){return false;}}_parse_status = RECV_HTTP_BODY;return true;}bool RecvBody() // 接收請求正文{if(_parse_status != RECV_HTTP_BODY){return false;}size_t content_size = _request.GetLength(); //這也是固定格式,就是正文中會有一行是表示正文長度的if(content_size == 0) //因為正文可能有也可能沒有{_parse_status = RECV_HTTP_OVER;return true;}//因為正文可能會很長int real_size = content_size - _request._body.size();//如果緩沖區數據充足if(buf->ReadAbleSize() >= real_size){_request._body.append(buf->ReadPos(), real_size);buf->MoveReadIndex(real_size);_parse_status = RECV_HTTP_OVER;return true;}//如果緩沖區數據不夠,先全拿出,但是不能設置狀態_request._body.append(buf->ReadPos(), buf->ReadAbleSize());buf->MoveReadIndex(buf->ReadAbleSize());return true;}
對于請求報頭來說,因為每行都是xxx: yyyyy\r\n;的格式,但是內容是不同的,所以我們要處理一行就解析一行,不然等你讀取完你再處理,你xxx對應的值是yyyy,不還是要把每行再分離出來嗎?所以就需要一個循環,取一行就解析一行。一直到讀取到空格行,也就是結束的標志。然后把狀態更新一下
對于請求正文來說,也有一些不同,因為一般情況下,正文的數據會非常的多,一次性會處理不完,處理不完怎么辦呢,那么我們就讀取一點拿過來一點,然后記錄下這個讀取長度。在請求頭部中,會有個記錄請求正文長度的,我們獲取到這個長度之后,然后減去這個已經存儲的長度,就是剩余我們還需要的長度,然后下次再進行判斷,如果緩沖區的數據大于了還需要的長度,就說明已經有充足的數據了,然后就直接把剩余的數據追加到之前的數據后面就可以了,接著更新狀態
解析函數
首先,我們最先從緩沖區獲取的數據也就是請求行數據,那肯定最先用的就是解析請求行函數了
解析請求行的時候我們使用的正則表達式是這個:
(GET|HEAD|POST|PUT|DELETE) ([^?]*)(?:\\?(.*))? (HTTP/1\\.[01])(?:\n|\r\n)?
?在這個正則表達式的匹配結果中,如果我們的url中沒有攜帶參數,那么參數部分的匹配結果就是一個空串,他也是在matches里面的,這一點我們不需要關心,因為后續我們解析參數的時候會將這種情況給他處理了。
然后我們用?bool ret = regex_match(line, matches, e); 去把獲取到的數據放在maches中,然后通過matches[1],[2],[...]放到我們定義的HttpRequest對象中存儲起來。
query
變量存儲的是從HTTP請求URL中提取的查詢字符串(query string)部分。
具體來說,當HTTP請求有如下形式時:
GET /path/to/resource?name=value&another=data HTTP/1.1
query
變量會存儲 name=value&another=data
這部分內容。
在正則表達式匹配中,matches[3]
對應的是第三個捕獲組 (?:\\?(.*))?
中的 (.*)
部分,也就是問號 ?
后面的所有內容,直到空格之前。
之后的代碼會進一步處理這個查詢字符串:
- 使用
&
分隔符將查詢字符串分割成多個鍵值對 - 對每個鍵值對,查找
=
的位置來分離鍵和值 - 對鍵和值進行URL解碼(處理百分號編碼和特殊字符)
- 將解碼后的鍵值對存儲到請求對象中
最后就是
- 從已經找到的每個查詢字符串部分(如"name=value")中,使用等號("=")的位置將字符串分割成兩部分
string key = Util::UrlDecode(str.substr(0, pos),true);
- 提取等號前面的部分作為鍵(key)
- 使用
str.substr(0, pos)
獲取從字符串開始到等號位置的子字符串 - 然后用
Util::UrlDecode
進行URL解碼,true
參數表示將加號(+)轉換為空格
string val = Util::UrlDecode(str.substr(pos+1),true);
- 提取等號后面的部分作為值(value)
- 使用
str.substr(pos+1)
獲取從等號后一個位置到結尾的子字符串 - 同樣進行URL解碼,
true
參數表示將加號轉換為空格
_requset.SetParam(key,val);
- 將解碼后的鍵值對添加到HTTP請求對象中
- 這樣應用程序就可以通過HTTP請求對象訪問這些查詢參數
bool ParseLine(const string &line) // 解析請求行{smatch matches;std::regex e("(GET|HEAD|POST|PUT|DELETE) ([^?]*)(?:\\?(.*))? (HTTP/1\\.[01])(?:\n|\r\n)?");bool ret = regex_match(line, matches, e);if(ret == false){_parse_status = RECV_HTTP_ERR;_status_code = 400; // BAD REQUESTreturn false;}//存儲請求的方法,資源路徑,協議版本_request._method = matches[1];_request._path = Util::UrlDecode(matches[2],false); //資源路徑需要解碼,但是只有查詢字符串才要空格轉加號_request._version = matches[4];string query = matches[3];//分三步:1.分割&前后的內容放入vector中 2.循環查找=的位置,分割=前后的內容,前面是key,后面是val 3.存儲kvvector<string> query_array;Util::Spilt<query, "&", &query_array>;for(auto &str : query_array){size_t pos = str.find("=");if(pos == string::npos) //這個是固定格式,如果沒有找到=就說明是錯誤的{_parse_status = RECV_HTTP_ERR;_status_code = 400; // BAD REQUESTreturn false;}string key = Util::UrlDecode(str.substr(0, pos),true);string val = Util::UrlDecode(str.substr(pos+1),true);_requset.SetParam(key,val);}return true;}
接下來就是解析報頭了,先把每行中的最后\n\r給刪掉,然后去找每行的固定格式: 找到之后更新狀態就行了
bool ParsesHead(string &line) // 解析請求報頭{//先刪除末尾無用字符if(line.back() == "\n"){line.pop_back();}if(line.back() == "\n"){line.pop_back();}size_t pos = line.find(": "); //這個也是固定格式if(pos == string::npos){_parse_status = RECV_HTTP_ERR;_status_code = 400; // BAD REQUESTreturn false;}}
?為什么會沒有解析正文這個函數呢?
- 靈活性考慮 - HTTP請求正文有多種格式(JSON、XML、表單數據、二進制數據等),不同應用需要不同的解析方式。如果在庫中內置特定的解析方法,可能會限制庫的通用性。
- 分離關注點 - 這符合"關注點分離"的軟件設計原則,網絡庫負責網絡通信和基本協議解析,應用層代碼負責特定業務邏輯和數據格式解析。
- 避免依賴膨脹 - 如果實現各種正文格式的解析,可能需要引入額外的依賴庫(如JSON解析庫),這會使muduo庫變得臃腫。
- 性能考慮 - 通用的解析方法可能無法滿足特定應用的性能需求,讓用戶自行實現可以針對特定場景進行優化。
在實際使用中,muduo的這種設計讓開發者可以根據自己的需求選擇適合的請求正文解析方式,比如對于JSON格式可以使用rapidjson,對于表單數據可以自行實現解析邏輯等。這提供了更大的靈活性,也符合C++庫的設計理念。
公有接口
HttpContext() - 構造函數,初始化解析狀態為接收請求行(RECV_HTTP_LINE),狀態碼為200(成功)。?
void ReSet() - 重置函數,將狀態恢復到初始狀態: 重置狀態碼為200?
重置解析狀態為接收請求行?
清空請求對象?
int StatusCode() - 返回當前HTTP狀態碼。?
HttpParseStatu ParseStatus() - 返回當前的解析狀態(如接收請求行、接收頭部等)。?
HttpRequest& Request() - 返回已解析的HTTP請求對象的引用,供上層訪問。?
void RecvHttpRequest(Buffer *buf) - 核心解析函數,根據當前解析狀態調用相應的處理函數: 如果是接收請求行狀態,調用RecvLine?
如果是接收頭部狀態,調用RecvHead?
如果是接收正文狀態,調用RecvBody
HttpContext():_parse_status(RECV_HTTP_LINE),_status_code(200){}void ReSet(){_status_code(200);_parse_status(RECV_HTTP_LINE);_request.Reset();}//返回狀態碼int StatusCode(){return _status_code;}//返回解析狀態HttpParseStatu ParseStatus(){return _parse_status;}//返回已經解析并處理的請求信息HttpRequest& Request(){return _request;}void RecvHttpRequest(Buffer *buf){switch (_parse_status){case RECV_HTTP_LINE:RecvLine(buf);case RECV_HTTP_HEAD:RecvHead(buf);case RECV_HTTP_BODY:RecvBody(buf);default:break;}}
疑惑點
讀取請求的接口,為什么不要break?
- 提高解析效率 - 允許在一次函數調用中盡可能多地解析數據。例如,如果緩沖區中同時包含了請求行和請求頭部的數據,這種設計可以一次性處理完所有可用數據,而不必等待下一次調用。
- 狀態機連續處理 - HTTP解析是一個狀態機過程,當一個狀態處理完成后,如果有更多數據可以處理,應該立即進入下一個狀態進行處理。
- 最大化緩沖區利用 - 充分利用每次
RecvHttpRequest
調用處理盡可能多的數據,減少處理延遲。
解析和接收兩個意思一樣嗎?
接收(Receiving):
- 指的是從網絡中獲取原始數據(字節流)的過程
- 屬于網絡 I/O 操作,涉及到套接字讀取
- 關注的是"如何獲取數據"
- 例如:從 TCP 連接讀取字節流到緩沖區
解析(Parsing):
- 指的是將已接收的原始數據轉換為結構化信息的過程
- 屬于數據處理操作,涉及到語法分析
- 關注的是"如何理解數據"
- 例如:將 HTTP 原始報文分解為請求行、頭部字段、請求體等
在 HTTP 服務器的工作流程中:
- 首先接收原始的 HTTP 請求報文(字節流)
- 然后解析這些字節流,提取出各種 HTTP 請求組件
- 最后基于解析結果進行業務處理
?為什么需要設置單個接收函數,比如接收請求行,接收請求報頭,難道不能設置一個函數用于接收整個報文嗎?
為什么要解析呢 它既然接收成功了不就說明是一個完整的嗎??
解析就好比翻譯,你收到了一封外國友人的信,他是用他們國家的語言寫的,你雖然有一個完整的信,但是你讀不懂內容,所以就需要翻譯信的內容了?
std::regex e("(GET|HEAD|POST|PUT|DELETE) ([^?]*)(?:\\?(.*))? (HTTP/1\\.[01])(?:\n|\r\n)?");?這個是什么?
_request._path = Util::UrlDecode(matches[2],false); //資源路徑需要解碼,但是只有查詢字符串才要空格轉加號