介紹HTTP協議基本結構與基本實現HTTPServer
HTTP協議
前面已經了解了協議的重要性并且已經定義了屬于我們自己的協議,但是在網絡中,已經有一些成熟的協議,最常用的就是HTTP協議
在互聯網世界中,HTTP(HyperText Transfer Protocol,超文本傳輸協議)是一個至關重要的協議。它定義了客戶端(如瀏覽器)與服務器之間如何通信,以交換或傳輸超文本(如HTML文檔)
HTTP協議是客戶端與服務器之間通信的基礎。客戶端通過HTTP協議向服務器發送請求,服務器收到請求后處理并返回響應。HTTP協議是一個基于TCP協議的無連接、無狀態的協議,即每次請求都需要建立新的連接,且服務器不會保存客戶端的狀態信息
因為HTTP是基于TCP的,而TCP是面向字節流的,所以在傳遞信息時肯定會存在收到的信息不完整的情況,那么根據前面的經驗,不論是客戶端和服務端都需要做數據完整性檢查
URL與URI
URI(統一資源標識符)是用于標識網絡資源的字符串,而URL(統一資源定位符)是URI的一個擴展,它不僅標識資源,還提供了定位該資源的方法
一個標準的URL通常包含以下幾個部分:
http://www.example.com:80/path/to/resource?param1=value1¶m2=value2#fragment
- 協議(
http://
):指定客戶端和服務器之間通信使用的協議 - 主機名(
www.example.com
):資源所在服務器的域名或IP地址 - 端口號(
:80
):服務器上運行的服務的端口號(HTTP默認為80) - 路徑(
/path/to/resource
):資源在服務器上的具體位置 - 查詢參數(
?param1=value1¶m2=value2
): 發送給服務器的額外參數 - 片段標識符(
#fragment
): 資源內的特定部分的引用
深入URL結構
仔細觀察URL的結構,除去協議、主機名和端口號以外,剩余的部分正好就是文件的路徑,而在操作系統之下標記一個文件的路徑也是使用同樣的結構,所以這里就可以得出一個結論:URL的結構實際上就是找到對應的主機,再在該主機上找到指定路徑下的文件
兩臺計算機進行網絡通信實際上就是在做IO,而要做IO就需要有對應的數據交換,所以首先就必須要定位到文件的位置,定位文件就是通過路徑進行的,所以URL的設計思想對應的就是操作系統定位文件的方式,但是需要注意在URL中,路徑開始的第一個/
并不代表著操作系統的根目錄,而是Web應用的根目錄
URL編碼
URL編碼是一種將非ASCII字符和特殊字符轉換為可在URL中安全傳輸的編碼方式。因為URL只能使用ASCII字符集中的一部分字符,所以需要對其他字符進行編碼
編碼原理
URL編碼遵循以下規則:
-
ASCII字母、數字和某些特殊字符保持不變,即:
- 字母:
A-Z
,a-z
- 數字:
0-9
- 特殊字符:
-
,_
,.
,~
- 字母:
-
其他字符(如空格、中文、特殊符號)按照以下步驟編碼:
- 將字符轉換為字節序列(通常使用UTF-8編碼)
- 每個字節用百分號
%
后跟兩位十六進制數表示
常見編碼示例
字符 | URL編碼 | 說明 |
---|---|---|
空格 | %20 (或 + ) | 空格可以編碼為%20 或+ (表單提交時) |
! | %21 | 感嘆號 |
# | %23 | 井號(必須編碼,因為在URL中有特殊含義) |
$ | %24 | 美元符號 |
& | %26 | 和號(必須編碼,因為用于分隔參數) |
+ | %2B | 加號 |
/ | %2F | 正斜杠(在路徑中通常不編碼) |
: | %3A | 冒號(在協議分隔符后通常不編碼) |
= | %3D | 等號(在查詢參數中通常不編碼) |
? | %3F | 問號(必須編碼,因為用于引導查詢參數) |
@ | %40 | at符號 |
中 | %E4%B8%AD | 漢字"中"的UTF-8編碼 |
編碼示例
原始URL中包含中文和特殊字符:
https://example.com/搜索?關鍵詞=中國&category=歷史
編碼后的URL:
https://example.com/%E6%90%9C%E7%B4%A2?%E5%85%B3%E9%94%AE%E8%AF%8D=%E4%B8%AD%E5%9B%BD&category=%E5%8E%86%E5%8F%B2
URL編碼的應用場景
- 查詢參數傳遞:確保包含特殊字符的參數能正確傳遞
- 表單提交:當使用GET方法提交表單時,表單數據會編碼到URL中
- 國際化URL:包含非英語字符的URL需要編碼
- 防止字符注入攻擊:編碼可以防止某些特殊字符被解釋為代碼
注意事項
- 不同的字符集可能產生不同的編碼結果,現代Web應用通常使用UTF-8
- 某些舊系統可能使用非標準編碼(如GB2312)
- 瀏覽器會自動對輸入的URL進行編碼
- 在服務器端編程中,各種語言都提供了URL編碼和解碼的函數
- 編碼應該在正確的位置進行,不要對整個URL進行編碼,而是對各部分中的特殊字符進行編碼
URL編碼確保了含有特殊字符的數據可以通過URL安全傳輸,是Web應用開發中的基礎知識
HTTP請求結構和HTTP響應結構
基本認識
既然是傳遞數據,那么肯定要做的就是確定雙方都認識的一個結構,這樣獲取到的數據才能被正確解釋,在HTTP中,有兩種結構,分別是HTTP請求和HTTP響應
一個基本的HTTP請求結構如下圖所示:
一個基本的HTTP響應結構如下圖所示:
在上面兩個結構圖中,可以看到都存在換行符,這里的換行符更準確來說應該是\r\n
但是這僅僅只是定義了結構,根據前面的知識可以知道除了有結構以外,還需要對結構化的數據進行序列化和反序列化,HTTP協議的設計者也考慮到了這一點,所以HTTP實際上有自己的序列化和反序列化方式,而不需要程序員自己去實現前面類似于使用JSONCPP庫進行JSON格式字符串的來回轉換
HTTP請求結構
在HTTP請求結構中:
首先是請求行部分,其中第一個就是HTTP的請求方法,具體的方法一共有6種,但是最常見的就是GET
和POST
方法,具體二者的區別會在后面的章節進行講解;接著是URI
實際上對應的就資源路徑;最后就是HTTP版本,具體在HTTP版本部分會有具體介紹,此處不具體說明
第二部分就是請求報頭,請求報頭所有的屬性都是以key: value
的形式存在,具體存在哪些屬性在后面的章節會提及,此處不具體說明
最后就是請求正文,這里一般存放著請求時傳遞給服務器的參數,但是具體是否存在請求參數需要看請求方式,具體見后面的章節進行講解
HTTP響應結構
在HTTP響應結構中:
首先是響應行部分,第一個就是HTTP版本,之所以HTTP請求和響應中都需要指定版本號是為了確保通信雙方能夠正確理解和處理彼此的消息,處理版本差異,并在不同版本的HTTP協議間提供平滑的過渡。這種設計使得HTTP協議能夠在保持向后兼容的同時不斷發展和改進;接著是狀態碼,HTTP協議規定,不論是請求成功還是請求失敗都需要給客戶端響應,但是如果一味地響應同一種數據,那么客戶端就無法分辨哪一個為正常數據,所以為了標識不同的響應結果,就需要這個狀態碼,在深入HTTP序列化和反序列化有具體講解,此處不具體說明;最后就是狀態碼描述,這個描述文字對應的狀態碼的含義,具體見深入HTTP序列化和反序列化
第二部分和第三部分與HTTP請求結構的第二部分相同,此處不再贅述
封裝網絡接口
接下來需要編寫處理HTTP請求的服務器,而因為HTTP是基于TCP的,所以為了后面編寫的方便,此處先對前面使用的網絡接口進行封裝
基本設計思路
本次封裝考慮采用模版方法設計模式(Template Method Pattern),其是一種行為型設計模式,它定義了一個算法的框架,但將一些步驟的具體實現延遲到子類中。通過這種方式,模板方法允許子類在不改變算法結構的情況下重新定義某些步驟
該模式的核心思想是:在父類中定義算法的整體結構,而將某些具體步驟留給子類去實現 。這樣可以確保算法的骨架保持一致,同時允許子類根據需要自定義某些細節
在模版方法設計模式中存在一些核心概念:
- 抽象類(Abstract Class):定義了算法的整體框架,并包含一個或多個抽象方法(由子類實現)。此外,它還可能包含一些具體方法(默認實現),這些方法可以直接被子類復用
- 模板方法(Template Method):模板方法是一個具體方法,一般不允許被子類重寫。它定義了算法的執行步驟,調用了抽象方法和具體方法
- 具體類(Concrete Class):具體類(子類)繼承抽象類,并實現抽象方法,提供具體的實現邏輯
根據這個模式的基本介紹,下面考慮具體的設計思路:
首先是抽象類,定義為BaseSocket
,其中提供一系列抽象方法和一個具體模版方法initSocket
,在具體模版方法中需要調用抽象方法,當子類實現抽象方法后,通過向上轉型調用抽象類中的具體方法
接著是具體類,具體類一共有兩個,一個是UdpSocket
,另外一個是TcpSocket
,因為本次需要實現HttpServer
,所以重點實現TcpSocket
類
在具體方法中,因為是針對TcpSocket
,所以需要經過下面的三個步驟:
- 創建服務器監聽套接字
- 綁定服務器監聽套接字
- 服務器進入監聽狀態
根據這三個步驟,在initSocket
函數中就需要對應的三個方法的調用
設計抽象類
根據上面的思路,可以設計出抽象類如下:
class BaseSocket
{
public:virtual ~BaseSocket() = default;// 創建套接字virtual void createSocket() = 0;// 綁定virtual void toBind() = 0;// 監聽virtual void toListen() = 0;// 具體實現方法void initSocket(){createSocket();toBind();toListen();}
};
設計具體類
本次以TCP為例,設計TcpServer
類,需要繼承BaseServer
并實現對應的抽象方法,需要注意,子類不允許實現initServer
方法,實現方式與前面類似,只是將具體邏輯抽取到單獨的函數中,代碼如下:
// 默認端口
const uint16_t default_port = 8080;
// 默認最大支持排隊等待連接的客戶端個數
const int max_backlog = 8;class TcpSocket : public BaseSocket
{
public:TcpSocket(int port = default_port): _listen_socketfd(-1), _s_addr_in(port){}// 實現創建套接字void createSocket() override{_listen_socketfd = socket(AF_INET, SOCK_STREAM, 0);if (_listen_socketfd < 0){LOG(LogLevel::FATAL) << "監聽套接字創建失敗:" << strerror(errno);exit(static_cast<int>(ErrorNumber::SocketFail));}LOG(LogLevel::INFO) << "監聽套接字創建成功:" << _listen_socketfd;}// 實現綁定void toBind() override{int n = bind(_listen_socketfd, &_s_addr_in, _s_addr_in.getLength());if (n < 0){LOG(LogLevel::ERROR) << "綁定失敗:" << strerror(errno);exit(static_cast<int>(ErrorNumber::BindSocketFail));}LOG(LogLevel::INFO) << "綁定成功";}// 實現監聽void toListen() override{int ret = listen(_listen_socketfd, max_backlog);if (ret < 0){LOG(LogLevel::ERROR) << "監聽失敗:" << strerror(errno);exit(static_cast<int>(ErrorNumber::ListenFail));}LOG(LogLevel::INFO) << "監聽成功";}~TcpSocket(){}private:int _listen_socketfd; // 監聽套接字SockAddrIn _s_addr_in; // 套接字結構
};
接著,因為TCP還需要一個接收過程,所以還需要一個接口用于接收,并且為了讓上層處理具體的客戶端信息,考慮將客戶端信息返回給上層
根據這個思路,首先在抽象類中定義接收抽象函數和獲取接收套接字函數:
// 接收
virtual SockAddrIn toAccept() = 0;
接著在具體類中實現:
// 實現接收
SockAddrIn toAccept() override
{struct sockaddr_in peer;socklen_t length = sizeof(peer);int ac_socketfd = accept(_listen_socketfd, reinterpret_cast<struct sockaddr *>(&peer), &length);if(ac_socketfd < 0){LOG(LogLevel::ERROR) << "接收失敗:" << strerror(errno);exit(static_cast<int>(ErrorNumber::AcceptFail));}LOG(LogLevel::INFO) << "接收成功:";// 向上層返回客戶端return peer;
}
實現TcpServer
類
有了基本接口的封裝后,因為HttpServer
基于TCP,所以需要先實現TcpServer
類,基本思路如下:
對于TcpServer
類來說,需要考慮的事情就是執行任務,而因為是服務器,本次考慮只讓服務器做監聽和接收而不進行任何其他任務,所以考慮使用回調函數將任務交給上層。但是現在就遇到了一個問題,TcpSocket
的ac_socketfd
是一個局部變量,并且此時沒有相應的接口可以獲得該局部變量,所以為了解決這個問題,可以考慮對開始的toAccept
函數進行改寫
首先,為了外部可以獲取到客戶端的信息,可以考慮設計一個輸出型參數:
void toAccept(SockAddrIn *client)
{// ...
}
接著,為了外部可以直接使用接收成功后的套接字,這里直接將函數返回值修改為int
即可:
int toAccept(SockAddrIn *client)
{// ...
}
最后,整體修改toAccept
函數的邏輯:
// 實現接收
int toAccept(SockAddrIn* client) override
{struct sockaddr_in peer;socklen_t length = sizeof(peer);int ac_socketfd = accept(_listen_socketfd, reinterpret_cast<struct sockaddr *>(&peer), &length);if (ac_socketfd < 0){LOG(LogLevel::ERROR) << "接收失敗:" << strerror(errno);exit(static_cast<int>(ErrorNumber::AcceptFail));}LOG(LogLevel::INFO) << "接收成功:";*client = peer;// 向上層返回客戶端return ac_socketfd;
}
既然服務器要做監聽和接收,那么少不了的就是調用TcpSocket
中的監聽和接收接口,而因為監聽是在初始化就開始了,所以只需要在啟動時處理接收即可,接著將其他任務交給上層,但是,交給上層之前,需要處理好接收和任務分離,保證接收和執行任務可以獨立進行,此時就需要利用前面的線程或者進程,本次考慮使用線程,所以基本代碼如下:
using task_t = std::function<void(SockAddrIn, int)>;
using base_socket_ptr = std::shared_ptr<BaseSocket>;class TcpServer;struct data
{SockAddrIn client;int ac_socketfd;TcpServer* self;
};class TcpServer
{
public:TcpServer(uint16_t port = default_port):_bs(std::make_shared<TcpSocket>(port)),_isRunning(false){_bs->initSocket();}// 注冊方法void registerFunc(task_t handler){_handler = handler;}static void* routine(void* args){struct data* ptr = reinterpret_cast<struct data*>(args);ptr->self->_handler(ptr->client, ptr->ac_socketfd);return NULL;}void start(){_isRunning = true;while (_isRunning){SockAddrIn client;// 因為在SockAddrIn內部重載了&,此處注意使用addressof取出當前對象的實際地址int ac_socketfd = _bs->toAccept(std::addressof(client));pthread_t pid;struct data d = {client, ac_socketfd, this};pthread_create(&pid, NULL, routine, &d);}}// 獲取底層接口base_socket_ptr getSocketPtr(){return _bs;}private:std::shared_ptr<BaseSocket> _bs; // 向上轉型bool _isRunning;task_t _handler;
};
實現HTTP服務器
有了基本的TcpServer
類后就可以開始實現基于TCP的HTTP服務器,為了能夠快速看到服務器運行效果,先搭建一個基本的服務器端。這個服務器需要實現的功能:當瀏覽器請求服務端時可以看到服務端給瀏覽器回顯的一行HTML內容
有了目標功能后,現在考慮如何實現這個功能。既然要給瀏覽器回顯一行HTML內容,那么服務器就必須要有給客戶端返回HTTP響應的功能。需要注意,本次客戶端只是發送一個不含有任何請求報頭和請求體的HTTP請求,所以服務器可以不用對客戶端的請求進行細致化處理,具體如何細致化處理會在深入HTTP序列化和反序列化
創建HttpServer
類
因為HTTP是基于TCP的,所以在HttpServer
類中需要有一個TcpServer
成員,接著根據前面的思路,需要提供處理HTTP請求的函數,所以整體結構如下:
class HttpServer
{
public:HttpServer(uint16_t port = default_port):_tp(std::make_shared<TcpServer>(port)){}void handleHttpRequest(SockAddrIn sock_addr_in, int ac_socketfd){}private:std::shared_ptr<TcpServer> _tp;
};
但是,上面的基本結構中還缺少一個關鍵的環節:啟動服務器。啟動HTTP服務器本質就是啟動TcpServer
,啟動TcpServer
時需要傳遞執行的任務,因為本次TcpServer
執行的就是處理HTTP請求,所以執行的函數就是handleHttpRequest
,所以實現如下:
void start()
{// 注冊方法_tp->registerFunc([this](SockAddrIn sock_addr_in, int ac_socketfd){this->handleHttpRequest(sock_addr_in, ac_socketfd);});_tp->start();
}
實現處理HTTP請求接口
根據前面提到的目標,服務器只需要根據HTTP響應結構向客戶端返回對應的內容即可。首先,構建一個內容作為HTTP響應結構的響應體:
std::string msg = "<h1>Hello Linux</h1>";
const std::string sep = "\r\n";
std::string body = msg + sep;
std::string status_line = "HTTP/1.1 200 OK" + sep + sep;std::string httpResponse = status_line + body;
接著要實現發送,就必須要調用對應的接口,即send
,所以基本實現如下:
void handleHttpRequest(SockAddrIn sock_addr_in, int ac_socketfd)
{LOG(LogLevel::INFO) << "收到來自:" << sock_addr_in.getIp() << ":" << sock_addr_in.getPort() << "的連接";// 不對客戶端發送的HTTP請求進行處理// 向客戶端發送一個HTTP響應std::string msg = "<h1>Hello Linux</h1>";const std::string sep = "\r\n";std::string body = msg + sep;std::string status_line = "HTTP/1.1 200 OK" + sep + sep;std::string httpResponse = status_line + body;ssize_t ret = send(ac_socketfd, httpResponse.c_str(), httpResponse.size(), 0);(void)ret;
}
測試
測試代碼如下:
#include "HttpServer.hpp"
#include <memory>using namespace HttpServerModule;int main(int argc, char* argv[])
{uint16_t port = std::stoi(argv[1]);std::shared_ptr<HttpServer> hs = std::make_shared<HttpServer>(port);hs->start();return 0;
}
編譯運行后打開瀏覽器輸入對應的IP地址和端口號即可看到一個客戶端收到的內容:
封裝發送和接收接口
在上面最后一步:向客戶端發送HTTP響應時使用的是底層send
接口,既然是底層接口,同樣可以考慮對這個接口進行封裝,同時也對接收接口進行封裝。因此,封裝的基本思路為:首先在BaseSocket
基類中創建接口和發送兩個接口的聲明,再在具體實現類TcpSocket
中實現這兩個接口:
=== “BaseSocket
”
// 發送數據
virtual void sendData(std::string &in_data, int ac_socketfd) = 0;
// 接收數據
virtual void recvData(std::string &out_data, int ac_socketfd) = 0;
=== “TcpSocket
”
// 接收數據void recvData(std::string &out_data, int ac_socketfd) override{char buffer[4096] = {0};ssize_t ret = recv(ac_socketfd, buffer, sizeof(buffer), 0);if(ret > 0){out_data = buffer;}}// 發送數據void sendData(std::string &in_data, int ac_socketfd) override{ssize_t ret = send(ac_socketfd, in_data.c_str(), in_data.size(), 0);(void)ret;}
但是,現在又遇到了第二個問題,因為接收數據接口和發送數據接口都是TcpSocket
類中的,而在HttpServer
中是TcpServer
,無法直接調用到TcpSocket
中的接收和發送接口,這里最直接的思路就是在TcpServer
中提供一個接口返回底層的TcpSocket
類或者其基類對象:
socket_ptr getSocketPtr()
{return _bs;
}
此時在上層就可以調用該接口獲取到調用對象的指針,對應的整體處理HTTP請求接口實現如下:
void handleHttpRequest(SockAddrIn sock_addr_in, int ac_socketfd)
{LOG(LogLevel::INFO) << "收到來自:" << sock_addr_in.getIp() << ":" << sock_addr_in.getPort() << "的連接";// 不對客戶端發送的HTTP請求進行處理// 向客戶端發送一個HTTP響應// ...std::shared_ptr<BaseSocket> bs = _tp->getSocketPtr();bs->sendData(httpResponse, ac_socketfd);
}
再次編譯運行后打開瀏覽器輸入對應的IP地址和端口號即可看到一個客戶端收到的內容: