簡介

在本系列文章中,我將逐步介紹如何使用 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 表示我們使用 IPv4SOCK_STREAM 表示我們使用 TCPIPPROTO_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 表示我們使用 IPv4sin_port = htons(8080) 表示我們使用連接埠 8080htons() 用來將連接埠號轉換為網路位元組順序。

我們使用 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 結構體中。如果成功,它會回傳用戶端檔案描述元;如果失敗,則回傳 -1client_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(),將 recvsend 函式放在迴圈內。

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() 的參數分別改為 bufferbytes,而不是 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 回應