簡介
在本系列文章中,我將逐步介紹如何使用 POSIX API 以 C++ 從零開始建立 HTTP 伺服器,並實際讓它在瀏覽器中提供具有 CRUD 操作的網頁——同時它也能同時為多個用戶端提供服務!
注意:我使用的是 C++ 17。我會將整個專案分成多個部分(目前可能是 4-5 篇部落格文章)。
你能從這個專案學到什麼?
首先,你會了解 TCP socket 的運作方式,以及如何使用它們來建立伺服器。你也會了解 HTTP 的運作方式以及如何解析它。你會學到如何建立網頁並將它提供給瀏覽器。最後,你會了解當嘗試為多個用戶端提供服務並處理數百萬個請求時,這個架構會變得有多複雜!雖然我不會製作能處理數百萬個請求的伺服器,但希望你能體會到實際為你提供這篇部落格文章的伺服器。
廢話不多說,讓我們從本系列的第一部分開始:TCP Socket!
直接進入正題
使用這些標頭檔:
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string>
進入全螢幕模式 離開全螢幕模式
建立 socket
為了讓伺服器能接受請求並回傳回應,我們需要一個讓伺服器和用戶端可以連線(互相通訊)的媒介。為此我們使用 socket。Socket 可以被視為在網路中運行的兩個程式之間的通訊端點。
int server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_fd < 0)
{
std::cerr << "error creating the socket" << std::endl;
return -1;
}
進入全螢幕模式 離開全螢幕模式
上述程式碼建立了一個 socket。AF_INET 表示我們使用 IPv4。SOCK_STREAM 表示我們使用 TCP。IPPROTO_TCP 表示我們使用 TCP 協定。或者你也可以傳遞 0 作為最後一個參數,核心就會根據 socket 類型決定協定(在本例中是 TCP,因為我們使用了 SOCK_STREAM)。
socket 函式會回傳一個整數檔案描述元,我們可以用它來參照這個 socket。可以將檔案描述元視為這個 socket 的唯一識別碼。如果檔案描述元是 -1,這表示建立 socket 失敗。
我建議在建立 socket 後加入這段程式碼:
int opt{1};
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
進入全螢幕模式 離開全螢幕模式
當你測試程式碼時,你可能需要多次啟動伺服器(就像我做的一樣)。但是如果你不執行上述步驟,你會得到一個錯誤,告訴你位址已被使用。這是因為在伺服器關閉後,socket 會卡在 TIME_WAIT 狀態。上述程式碼可以防止這種情況,並讓你在伺服器關閉後立即重複使用相同的位址。
將 socket 繫結到位址
現在我們有了 socket,但它還沒有連線到任何位址。所以我們需要將它繫結到一個位址。位址是由 IP 位址和連接埠號組成的。
struct sockaddr_in server_addr{};
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
std::cerr << "error binding to the socket" << std::endl;
return -1;
}
進入全螢幕模式 離開全螢幕模式
這裡的 server_addr 是一個結構體,用來存放伺服器的位址。sin_addr.s_addr = INADDR_ANY 表示伺服器將監聽所有可用的介面。sin_family = AF_INET 表示我們使用 IPv4。sin_port = htons(8080) 表示我們使用連接埠 8080。htons() 用來將連接埠號轉換為網路位元組順序。
我們使用 bind() 函式將伺服器繫結到位址,該函式需要 socket 檔案描述元、伺服器的位址,以及位址的大小作為參數。同樣地,如果這回傳 -1,就表示將 socket 繫結失敗。
監聽傳入的連線
現在伺服器已繫結到某個位址,這表示它有了一個家。但它仍然是關閉的,不接受訪客,所以我們需要讓它監聽傳入的連線。
if (listen(server_fd, 10) < 0){
std::cerr << "error listening on the socket" << std::endl;
return -1;
}
進入全螢幕模式 離開全螢幕模式
listen() 函式告訴核心這個 socket 是被動 socket,應該用來接受傳入的連線。第二個參數 10 是可以排隊的待處理連線數量上限。如果佇列已滿,傳入的連線將被拒絕。如果這回傳 -1,就表示監聽 socket 失敗。
整合在一起
現在我們已經準備好伺服器,可以開始接受連線了。但在此之前,我會將上述所有程式碼放入一個名為 startServer() 的函式中:
int startServer()
{
int server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_fd < 0)
{
std::cerr << "error creating the socket" << std::endl;
return -1;
}
int opt{1};
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in server_addr{};
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
std::cerr << "error binding to the socket" << std::endl;
return -1;
}
if (listen(server_fd, 10) < 0)
{
std::cerr << "error listening on the socket" << std::endl;
return -1;
}
return server_fd;
}
進入全螢幕模式 離開全螢幕模式
接受傳入的連線
讓我們將這個函式命名為 getClient()
int getClient(int server_fd)
{
struct sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0)
{
std::cerr << "error accepting the client" << std::endl;
return -1;
}
std::cout << "client connected to socket successfully! \n";
return client_fd;
}
進入全螢幕模式 離開全螢幕模式
上述程式碼現在透過對伺服器檔案描述元呼叫 accept() 來接受連線,並將用戶端位址儲存在 client_addr 結構體中。如果成功,它會回傳用戶端檔案描述元;如果失敗,則回傳 -1。client_fd 可以理解為用戶端 socket 的唯一識別碼。
用戶端與伺服器之間的通訊
由於我們的伺服器正在執行並接受用戶端,接下來我們需要接受它們的請求並提供適當的回應。但要做到這一點,我們需要從用戶端接收資料並將資料傳送到用戶端。
從用戶端接收資料
char buffer[8193]{};
int bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
buffer[bytes] = '\0';
進入全螢幕模式 離開全螢幕模式
我們使用 recv() 函式從用戶端接收資料。它需要用戶端檔案描述元來識別我們正在與哪個用戶端通訊、一個緩衝區來儲存資料、緩衝區的大小,以及最後一個設定為 0 的旗標以使用預設行為:它會阻塞直到接收到資料,從內部 socket 緩衝區讀取資料,將其複製到我們的緩衝區,並從 socket 緩衝區中移除(以避免將來重複接收)。它會回傳接收到的位元組數,如果失敗則回傳 -1,如果用戶端關閉連線則回傳 0。
將資料傳送到用戶端
// res is of type std::string and contains data to send.
if (send(client_fd, res.c_str(), res.size(), 0) < 0)
{
std::cerr << "Error sending reply: " << res << "\n";
}
進入全螢幕模式 離開全螢幕模式
我們使用 send() 函式將資料傳送到用戶端。它需要用戶端檔案描述元來識別我們正在與哪個用戶端通訊、要傳送的資料(作為 C 風格字串 - char *),以及要傳送的資料大小。它會回傳傳送的位元組數,如果失敗則回傳 -1。
持續處理請求
現在的情況是用戶端不會只發送一個請求。他們可以發送多個請求。所以我們需要持續處理它們。我們可以使用 while 迴圈來做到這一點。我會將這個函式命名為 handleClient(),將 recv 和 send 函式放在迴圈內。
void handleClient(int client_fd)
{
while (true)
{
char buffer[8193]{};
int bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (bytes < 0)
{
std::cerr << "Error receiving message \n";
break;
}
if (bytes == 0)
{
std::cerr << "Client Disconnected \n";
break;
}
buffer[bytes] = '\0';
/**
Parsing request
(Ignore this for now I will explain it in the next blog post)
**/
HttpRequest req = parse_request(buffer);
auto error = isValidRequest(req);
HttpResponse res{};
if (error)
{
res = error.value();
}
else
{
res = route(req);
}
/**
Building Appropriate HTTP Response
(Ignore this for now I will explain it in the next blog post)
**/
std::string http_res = build_response(res);
std::cout << "Received:\n" << buffer << "\n";
std::cout << "Sending:\n" << http_res << "\n";
if (send(client_fd, http_res.c_str(), http_res.size(), 0) < 0)
{
std::cerr << "Error sending reply: " << http_res << "\n";
}
}
close(client_fd);
std::cout << "Client: " << client_fd << " Has Disconnected\n";
}
進入全螢幕模式 離開全螢幕模式
我知道上述程式碼與接收和傳送資料的程式碼片段相比要多得多。這裡到底發生了什麼?好吧,我使用 while 迴圈讓用戶端可以持續發送請求而不關閉連線。請記住 recv 是一個阻塞呼叫,這表示它會停止,直到從用戶端取得資料或用戶端關閉連線。所以在它接收到資料並停止阻塞後,我會檢查接收是否失敗(bytes < 0)或用戶端是否離開(bytes == 0),在這些情況下 while 迴圈會中斷。否則,我會明確地將緩衝區設為空字元結尾(buffer[bytes] = '\0'),以便解析 C 字串的函式可以安全地讀取它。最後,我執行 close(client_fd) 在迴圈中斷時關閉連線。
成功接收資料後,我會呼叫函式 parse_request(buffer),它會解析請求並將其儲存在 HttpRequest 結構體中。我會在下一篇部落格文章中解釋這個函式。然後我會使用 isValidRequest(req) 檢查請求是否有效。如果請求無效,我會使用 send_error_response() 傳送錯誤回應。否則,我會使用 route(req) 將請求路由到適當的處理程式。最後,我使用 build_response(res) 建立 HTTP 回應,並使用 send(client_fd, res.c_str(), res.size(), 0) 將它傳送到用戶端。就這樣。
接收 -> 解析 -> 驗證 -> 建立回應 -> 傳送
連接所有內容
現在我們已經擁有所有必要的函式,讓我們將它們全部放在 main() 函式中。
int main()
{
int server_fd = startServer();
if (server_fd < 0)
{
return 1;
}
std::cout << "Server started on port 8080" << std::endl;
while (true)
{
int client_fd = getClient(server_fd);
if (client_fd < 0)
{
continue;
}
handleClient(client_fd);
}
// The below code would never execute as the while loop runs indefinitely.
close(server_fd);
return 0;
}
進入全螢幕模式 離開全螢幕模式
我們使用 while 迴圈讓我們的伺服器無限期執行,等待用戶端。(注意:在這個初始部分,handleClient() 在單一執行緒上循序處理連線。在本系列的未來部分,我們將使用多執行緒讓伺服器能同時處理多個用戶端連線)
想要測試伺服器嗎?
好吧,只要移除註解為「暫時忽略」的程式碼部分,並將 send() 的參數分別改為 buffer 和 bytes,而不是 http_res.c_str() 和 http_res.size()。
將檔案命名為 server.cpp。
現在使用這個指令在一個終端機中產生二進位檔案並執行它:
g++ -std=c++17 -Wall server.cpp -o server
進入全螢幕模式 離開全螢幕模式
./server
進入全螢幕模式 離開全螢幕模式
然後在另一個終端機中輸入 nc localhost 8080,然後在該終端機中輸入任何內容,例如 Hello!,並按下 Enter。你應該會在終端機中看到相同的文字被印出。在你輸入後,終端機會看起來「卡住」或「等待」;這是正常的,因為連線仍然開啟,伺服器正在等待更多請求。要關閉連線,請按 Ctrl+C。
接下來是什麼
在下一篇部落格文章中,我將解釋解析 HTTP 請求和建立 HTTP 回應。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.