目錄
設計思路
類的設計
模塊的實現
公有接口
私有接口
疑問點
設計思路
本模塊就是設計一個HttpServer模塊,提供便攜的搭建http協議的服務器的方法。那么這個模塊需要如何設計呢? 這還需要從Http請求說起。
首先從http請求的請求行開始分析,請求行里面有個方法。分為靜態資源請求和功能性請求的。
靜態資源請求顧名思義就是用來獲取服務器中的某些路徑下的實體資源,比如文件的內容等,這一類請求中,url 中的資源路徑必須是服務器中的一個有效的存在的文件路徑。比如:
- HTML/CSS/JavaScript文件
- 圖片(JPG、PNG、GIF等)
- 視頻和音頻文件
- PDF、文檔等靜態文件
- 字體文件
而如果提取出來的資源路徑并不是一個實體文件的路徑,那么他大概率是一個功能性請求,這時候就有用戶來決定如何處理這個請求了,也就是我們前面說過的 請求路徑 和 處理方法的路由表。
功能性請求如下
- 用戶登錄/注冊
- 商品搜索結果
- 個人資料頁面
- 訂單處理
- API接口調用
- 數據統計和報表生成
但是還有一種特殊的情況就是資源路徑是一個目錄,比如 / ,這時候有可能是一個訪問網站首頁的請求,所以我們需要判斷在這個路徑后面加上 index.html (也可以是其他的文件名,取決于你的網站的首頁的文件名) ,如果加上之后,路徑有效且存在實體文件,那么就是一個靜態資源請求,如果還是無效,那么就是一個功能性請求。
而功能性請求如何處理呢?這是由使用或者說搭建服務器的人來決定的。 用戶未來想要提供某些功能,可以讓他和某個虛擬的目錄或者說特定的路徑綁定起來。 比如提供一個登錄功能,那么用戶可以規定 ?/login 這個路徑就代表登錄的功能,未來如果收到了一個請求資源路徑是 /login ,那么就不是請求實體資源,而是調用網站搭建者提供的登錄的方法進行驗證等操作。 一般來說這些虛擬路徑不會和實體資源路徑沖突。
同時,對于這種功能性請求對應的路徑,他并不是說一個路徑只能有一個功能,不同的請求方法,同一個路徑,最終執行的方法也可以是不同的,這具體還是要看使用者的設定。
所以為了維護這樣的功能性路徑和需要執行的方法之間的映射關系,我們需要為每一種請求方法都維護一張路由表,路由表中其實就是保存了路徑和所需要執行的方法之間的映射關系。
在我們這里,就只考慮常用的五種方法,get,post,delete,head,put,其他的暫時就不提供支持了
//五張路由表using Handler = std::function<void(const HttpRequest&,HttpResponse*)>; using HandlerTable = std::unordered_map<std::string,Handler>;HandlerTable _get_route; HandlerTable _post_route; HandlerTable _head_route; HandlerTable _put_route; HandlerTable _delete_route;
這是交給用戶進行設置的,我們也會提供五個接口給用戶用來添加處理方法。
但是,這樣的表真的好嗎??
在實際的應用中,比如有以下的功能性請求的請求路徑 , /login1213 , /login12124 , /login1213626 , /login12152 , /login1295 , /login1275 ,對于這樣的一類路徑,他們其實需要執行的是同一個方法,而并不需要為每一個類似的路徑設置一個方法,而路徑后半部分的數字其實后續可以當成參數來用。
那么綜上所述,我們的路由表中作為 key 值的并不是 std::string ,而是只需要滿足某一種匹配要求的路徑,都可以執行某一方法,那么作為 key 值的其實是正則表達式。
using HandlerTable = std::unordered_map<std::regex,Handler>;
但是如果我們編譯一下就會發現,正則表達式是不能作為哈希的 key 值的,或者說不匹配默認的哈希函數。?
我們可以思考一下,我們用正則表達式作為 key 了,那么后面不管使用何種數據結構來存儲正則表達式和操作方法的映射關系,我們都是要遍歷整個路由表的,需要遍歷表中的所有的正則表達式,然后拿著我們的路徑來進行正則匹配,匹配上了就說明這是我們要找的方法,如果匹配不上就說明不是,不管怎么樣,都是要進行遍歷,那么其實我們直接用數組來存儲也是一樣的。
所以最終我們使用 vector 來存儲用戶方法。
using HandlerTable = std::vector<std::pair<std::regex,Handler>>;
而HttpServer模塊中除了五張路由表,還需要一個TcpServer對象,這是毋庸置疑的。 同時還需要保存一個網頁根目錄,這個根目錄是要交給用戶設置的,由使用者決定。
//支持Http協議的服務器
class HttpServer
{
private:TcpServer _server;std::string _base_path; //網頁根目錄//五張路由表using Handler = std::function<void(const HttpRequest&,HttpResponse*)>; using HandlerTable = std::vector<std::pair<std::regex,Handler>>;HandlerTable _get_route; HandlerTable _post_route; HandlerTable _head_route; HandlerTable _put_route; HandlerTable _delete_route; public:
};
類的設計
那么需要哪些接口呢?
首先需要提供給用戶的五個設置功能方法的接口,以及設置網頁根目錄和服務器線程數的接口。
還需要提供給用戶是否開啟超時釋放,以及啟動服務器的接口。
提供給用戶的接口就這么多,其實都很簡單,難的是私有的一些接口:
首先,未來拿到一個完整請求之后,我們需要能夠判斷這個請求是靜態資源請求還是功能性請求。
- 如果是資源性請求我們需要怎么做?
- 如果是功能性請求我們有需要怎么做?
最后還需要將相應組織成一個tcp報文進行回復。
同時還需要提供未來設置給TcpServer的連接建立和新數據到來的回調方法,這兩個方法是必需的,因為在連接建立時我們必須要設置上下文,在新數據到來時必須要有邏輯來決定怎么處理。
class HttpServer {
public://構造函數HttpServer();// 配置設置類方法void SetBaseDir(); // 設置靜態資源路徑的根目錄void SetThreadCount(); // 設置線程數量void Listen(); //開始監聽// 路由設置方法void Get(); // 設置GET方法void Post(); // 設置Post方法void Put(); // 設置Put方法void Delete(); // 設置Delete方法void Head(); // 設置Head方法private:// 連接和消息處理回調void OnConnected(); // 設置一個上下文void OnMessage(); // 把請求報文從應用層緩沖區提取出來// 請求處理和路由bool IsFileHandler(); // 判斷是否是靜態資源請求void Route(); // 通過請求類型分別進行處理void Dispatcher(); // 功能性請求處理void FileHandler(); // 靜態資源請求處理void ErrorHandler(); // 錯誤信息填充void WriteResponse(); //把響應對象轉換成響應報文并發送
};
模塊的實現
下面我將詳細的講解,這些模塊都是干嘛的,讓各位能有個更清晰的思路
公有接口
首先是設置靜態資源路徑根目錄,這個是為了開發者設定的,因為用戶在訪問的時候,很大概率是不會加上根目錄的,就好比你要在圖片網站上找一個蘋果的圖片,對于網站來說他這個蘋果肯定是分類在水果目錄下面的,你在輸入的時候大概率是直接apple.png,如果不進行設置默認根目錄的話,那么肯定是找不到的,當你設置了根目錄之后,你再輸入apple.png的時候,它就會默認的變成fruit/apple.png了。
而開發者在進行設置的情況下,可能會出現粗心,比如說這個目錄名字我少寫了一個字母,所以這個函數需要先去尋找是否存在這個目錄,要是不存在那就是開發者寫錯了,我要反饋給開發者,要是沒寫錯我就設置傳入的dir為根目錄
void SetBasedir(const string &dir)
{assert(Util::IsDirectory(dir) == true);_basedir = dir;
}
接著就是開發者設置下線程的數量了,直接就是傳入要設置的count就行
void SetThreadPoolCount(int count)
{_server.SetThreadCount(count);
}
然后就是開始監聽的接口了
void Listen() // 開始監聽{_server.Start();}
接下來就是路由表的使用了,對不同的方法的路由表都設置相應的正則表達式模式字符串和對應的回調函數,當用戶輸入了對應的請求方法的時候就去對應的路由表里查找。至于如何定義,那就是開發者來自定義了
void Get(const string &pattern, const Handler &handler)
{_get_route.push_back({regex(pattern), handler});
}void Post(const string &pattern, const Handler &handler)
{_post_route.push_back({regex(pattern), handler});
}void Put(const string &pattern, const Handler &handler)
{_put_route.push_back({regex(pattern), handler});
}void Delete(const string &pattern, const Handler &handler)
{_delete_route.push_back({regex(pattern), handler});
}
最后就是構造函數的實現了,需要傳入一個端口號來對我們內部的TcpServer對象進行初始化,然后包括三個內容,啟動非活躍連接銷毀,設置當連接來到的時候的回調函數用來設置上下文,以及把消息從緩沖區獲取的回調函數
HttpServer(int port, int timeout = DEFAULT_TIMEOUT): _server(port)
{// 啟用非活躍連接釋放_server.EnableInactiveRelease(timeout);// 設置連接回調函數_server.SetConnectedCallback(bind(&HttpServer::OnConnected, this, placeholders::_1));// 設置消息回調函數_server.SetMessageCallback(bind(&HttpServer::OnMessage, this, placeholders::_1, placeholders::_2));
}
私有接口
這些私有接口是由開發者去調用的,來設置一些信息,讓使用者去更好的使用
首先是設置給新連接設置一個上下文,用于當連接被切換的時候保存其中的數據,當下次再切換回來的時候,能接著當前的數據繼續進行操作,所以需要的參數就是一個新連接的引用,因為連接是會被放在TCP的全連接隊列中的
void OnConnected(const PtrConnection &conn) // 設置一個上下文{conn->SetContext(HtppContext());DBG_LOG("new connection %p", conn.get());}
接著是對于該新連接數據的提取,因為這些數據從TCP的接收緩沖區是先提取到用戶態中的緩沖區的,但是因為TCP是面向字節流的,也就是在用戶態的緩沖區存儲的數據都是以字節的形式,但是我Http要的是報頭,正文,請求行,這種類型的呀,所以肯定也是需要轉換的也就是用到了context的模塊,接著就是判斷請求的狀態碼,如果是狀態碼大于400了,就說明錯誤了,此時就需要進行處理了,可能你開發者想這個時候給用戶彈出一個錯誤網頁,所以接下來就調用ErrorHandler函數,然后把這個錯誤響應回去,再重置下HTTP上下文,準備處理新的請求,然后把緩沖區更新下也就是清空緩沖區,接著就是關閉連接了。如果是正常的狀態碼就返回了
void OnMessage(const PtrConnection &conn, Buffer *buf) // 處理從客戶端接收到的HTTP請求消息{while(buf->ReadAbleSize() > 0) // 只要緩沖區中還有可讀數據就繼續處理{HttpContext *context = conn->GetContext()->get<HttpContext>(); // 從連接上下文中獲取HTTP上下文對象context->RecvHttpRequest(buf); // 從緩沖區中解析HTTP請求數據HttpRequest &req = context->GetRequest(); // 獲取解析后的HTTP請求對象HttpResponse rsp; // 創建HTTP響應對象if(context->StatusCode() >=400) // 檢查HTTP狀態碼,如果大于等于400表示出錯{ErrorHandler(req,&rsp); // 調用錯誤處理函數生成錯誤響應WriteResponse(conn,req,rsp); // 將錯誤響應寫回客戶端context->Reset(); // 重置HTTP上下文,準備處理新的請求buf->MoveReadIndex(buf->ReadAbleSize()); // 清空緩沖區中剩余的所有數據conn->ShutDown(); // 關閉連接}return;}}
可能會有同學問了,這里的context->Reset(); ? buf->MoveReadIndex(buf->ReadAbleSize());?的是不是可以丟棄了,因為后面直接就是shutdown了
可以是可以但是這樣是不規范的,釋放鏈接是釋放鏈接,在釋放鏈接之前,我們正常去處理錯誤邏輯,將各個環節該置空的置空,該清理的清理,這是我們必須在代碼中進行體現的,這才是一個好的代碼習慣。并且來說,conn->shutdow還不是實際發送的邏輯,在后續還會進行一些判斷的,所以上層該做的工作還是要做的
這個操作之后,我們也就解析了二進制的數據了,然后判斷請求行的方法是什么類型的,是不是靜態資源的請求。需要四步:首先需要判斷有沒有設置資源根目錄。靜態資源請求的方法必須是 GET 或者 HEAD ,因為其他的方法不是用來獲取資源的。然后靜態資源請求的路徑必須是一個合法的路徑。最后就需要判斷請求的路徑的資源是否存在。但是我們需要考慮路徑是目錄的時候,給它加上一個 index.html。最后就是判斷文件是否存在
bool IsFileHandler(const HttpRequest &req)
{// 1.查看設置了靜態資源根目錄if(_basedir.empty())return false;// 2.請求方法必須是GET或HEADif(req._method != "GET" && req._method != "HEAD")return false;// 3.請求資源路徑必須合法if(Util::ValidPath(req._path) == false)return false;// 4.請求資源必須存在,且是一個普通文件// 為防止修改路徑,先復制一份string req_path = _basedir + req._path;// 先處理特殊情況:如果請求的是目錄,就換成請求首頁if(req._path.back() == '/')req_path += "index.html";// 如果不是普通文件就錯誤if(Util::IsRegular(req_path) == false)return false;return true;
}
接下來我們舉二個例子來更好的幫我們捋一捋思路?
例子1: 正常的圖片請求
- 用戶訪問
http://example.com/images/logo.png
- 瀏覽器發送 GET 請求,路徑為
/images/logo.png
- 函數檢查:
- 靜態根目錄已設置為
/var/www/html/
- 請求方法是 GET ?
- 路徑
/images/logo.png
是合法的 ? - 文件
/var/www/html/images/logo.png
存在且是普通文件 ?
- 靜態根目錄已設置為
- 函數返回 true,服務器會提供這個圖片文件
例子2: 請求目錄
- 用戶訪問
http://example.com/blog/
- 瀏覽器發送 GET 請求,路徑為
/blog/
- 函數檢查:
- 靜態根目錄已設置 ?
- 請求方法是 GET ?
- 路徑
/blog/
是合法的 ? - 檢測到路徑以
/
結尾,自動添加index.html
- 檢查
/var/www/html/blog/index.html
是否存在 - 如果存在,返回 true;如果不存在,返回 false
接下來就是開發者對于路由表的規則的設置了,首先就是判斷是什么類型的請求,如果是靜態資源請求就調用靜態資源的處理方法,如果是功能性的請求就匹配路由表中的方法,并且對不同方法設置不同的請求,如果兩種請求都不是就說明是錯的了
// 通過請求類型分別處理
void Route(HttpRequest &req, HttpResponse *rsp)
{// 是靜態資源請求就靜態資源處理if(IsFileHandler(req) == true)return FileHandler(req, rsp);// 動態性請求就動能性請求處理if(req._method == "GET" || req._method == "HEAD")return Dispatcher(req, rsp, _get_route);else if(req._method == "POST")return Dispatcher(req, rsp, _post_route);else if(req._method == "PUT")return Dispatcher(req, rsp, _put_route);else if(req._method == "DELETE")return Dispatcher(req, rsp, _delete_route);// 兩個都不是就返回405 METHOD NOT ALLOWEDrsp->_status = 405;return;
}
接下來就是對靜態資源請求的處理,首先構造一個完整的文件路徑,然后如果結尾是/就說明是訪問的首頁,然后把路徑讀響應到正文中,如果文件讀取失敗就說明錯誤了。然后用ExtMime把路徑中的最后一部分給分離出來,然后填入到響應報頭的Content-type中
// 靜態資源請求處理
void FileHandler(const HttpRequest &req, HttpResponse *rsp) // 處理靜態文件請求的函數
{// 讀取靜態文件資源,放到rsp的body,并設置mime// 判斷里面沒有修改資源路徑,所以在這里要修改string req_path = _basedir + req._path; // 構造完整的文件路徑if(req._path.back() == '/') // 如果請求路徑以'/'結尾(請求的是目錄)req_path += "index.html"; // 自動添加index.html作為默認頁面bool ret = Util::ReadFile(req_path, &rsp->_body); // 讀取文件內容到響應體if(ret == false) // 如果文件讀取失敗return; // 直接返回,不設置任何響應內容string mime = Util::ExtMime(req_path); // 根據文件擴展名獲取MIME類型rsp->SetHeader("Content-type", mime); // 設置Content-type響應頭return; // 處理完成,返回
}
接下來幾個例子,幫助大家理解
假設用戶通過瀏覽器訪問你的網站,請求了以下幾個不同的資源:
- HTML頁面請求:
- 用戶訪問
http://yourwebsite.com/about.html
- 服務器找到
about.html
文件 ExtMime
函數確定這是HTML文件,返回text/html
- 服務器在響應頭中設置
Content-type: text/html
- 瀏覽器收到響應后,看到這個MIME類型,知道應該將內容解析為HTML并渲染網頁
- 用戶訪問
- 圖片請求:
- 當HTML頁面中引用了圖片
<img src="logo.png">
- 瀏覽器請求
http://yourwebsite.com/logo.png
- 服務器找到
logo.png
文件 ExtMime
函數確定這是PNG圖片,返回image/png
- 服務器在響應頭中設置
Content-type: image/png
- 瀏覽器看到這個MIME類型,知道應該將內容解析為PNG圖片并顯示
- 當HTML頁面中引用了圖片
接著就是對動態請求的處理。先循環遍歷路由表表里面正則表達式是否匹配請求對象的資源路徑,以找到對應的處理函數填充響應對象,如果找不到就是404
// 功能性請求處理
void Dispatcher(HttpRequest &req, HttpResponse *rsp, Handlers &handlers) // 處理動態請求的分發器函數
{// 循環遍歷路由表表里面正則表達式是否匹配請求對象的資源路徑,以找到對應的處理函數填充響應對象for(auto &handler :handlers) // 遍歷所有注冊的處理器{const regex &e = handler.first; // 獲取當前處理器的正則表達式const Handler &func = handler.second; // 獲取當前處理器的處理函數bool ret = regex_match(req._path, req._matches, e); // 嘗試匹配請求路徑與正則表達式if(ret == false) // 如果不匹配continue; // 繼續檢查下一個處理器return func(req, rsp); // 找到匹配的處理器,調用對應的處理函數并返回}// 找不到就是404rsp->_status = 404; // 設置HTTP狀態碼為404(未找到)
}
接著就是把響應對象轉換成響應報文并發送。思路就是先把報頭完善一下,也就是進行判斷是短鏈接就把字段的Connection設置為close,否則就設置成keep-alive,然后判斷Content-Length有沒有填,ContentType應該填什么。要是重定向的話,就更新Location字段為重定向的url,這些都是必要的條件。當完成了這些之后,就可以構建響應報文了完善響應行,響應報頭,添加空行,添加響應正文,最后就是發送響應報文
// 把響應對象轉化成響應報文并發送
void WriteResponse(const PtrConnection &conn, const HttpRequest &req, HttpResponse &rsp) // 將HTTP響應對象序列化為HTTP報文并發送
{// 1.完善報頭if(req.Close() == true) // 如果請求要求關閉連接rsp.SetHeader("Connection", "close"); // 設置Connection頭為closeelsersp.SetHeader("Connection", "keep-alive"); // 否則設置為keep-alive保持連接if(rsp._body.empty() == false && rsp.HasHeader("Content-Length") == false) // 如果響應體不為空且沒有設置Content-Length頭rsp.SetHeader("Content-Length", to_string(rsp._body.size())); // 設置Content-Length頭為響應體大小if(rsp._body.empty() == false && rsp.HasHeader("Content-Type") == false) // 如果響應體不為空且沒有設置Content-Type頭rsp.SetHeader("Content-Type", "application/octet-stream"); // 設置默認的Content-Type為二進制流if(rsp._redirect_flag = true) // 如果需要重定向rsp.SetHeader("Location", rsp._redirect_url); // 設置Location頭指向重定向URL// 2.組織響應報文stringstream rsp_str; // 創建字符串流用于構建HTTP響應// 響應行rsp_str << req._version << " " << to_string(rsp._status) << " " << Util::StatusDesc(rsp._status) << "\r\n"; // 構建狀態行// 響應報頭for(auto &head : rsp._headers) // 遍歷所有響應頭{rsp_str << head.first << ": " << head.second << "\r\n"; // 添加每個響應頭到響應中}// 一個空行rsp_str << "\r\n"; // 添加空行分隔響應頭和響應體// 響應正文rsp_str << rsp._body; // 添加響應體// 3.發送響應報文conn->Send(rsp_str.str().c_str(), rsp_str.str().size()); // 發送完整的HTTP響應
}
最后就是設置錯誤信息,如果這個路徑是錯誤的,應該給用戶返回什么
void ErrorHandler(const HttpRequest &req, HttpResponse *rsp) // 處理HTTP錯誤的函數
{string body; // 創建一個字符串用于構建HTML錯誤頁面body += "<html>"; // HTML文檔開始標簽body += "<head>"; // 頭部開始標簽body += "<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>"; // 設置頁面元數據,指定內容類型和字符集body += "</head>"; // 頭部結束標簽body += "<body>"; // 正文開始標簽body += "<h1>"; // 一級標題開始標簽body += std::to_string(rsp->_status); // 添加HTTP狀態碼body += " "; // 添加空格body += Util::StatusDesc(rsp->_status); // 添加HTTP狀態碼的描述文本body += "</h1>"; // 一級標題結束標簽body += "</body>"; // 正文結束標簽body += "</html>"; // HTML文檔結束標簽// 響應正文類型是htmlrsp->SetContent(body, "text/html"); // 設置響應內容為HTML并指定MIME類型
}
疑問點
std::regex確實不能直接用作std::unordered_map的鍵
http不是管理的是協議的請求和響應嗎 這個?void OnConnect(const PtrConnection& conn) 連接不是應該由tcp去處理嗎?
“請求可能分多個TCP包到達,需要有地方存儲部分解析的數據,這個地方就是上下文” 那這個數據不是存儲在用戶的緩沖區當中的嗎?
HttpContext *context = conn->GetContext()->get<HttpContext>(); 這個代碼不懂