简介

在本系列中,我将介绍如何使用 POSIX API 在 C++ 中从零开始构建一个 HTTP 服务器,并真正让它在浏览器中提供带有 CRUD 操作的网页——同时它还能够同时为多个客户端提供服务!

注意:我使用的是 C++ 17。我会将整个项目分成多个部分(目前可能是 4-5 篇博客)。

从这个项目中你能学到什么?

首先,你会了解 TCP 套接字的工作原理以及如何使用它们来构建服务器。你还会了解 HTTP 的工作原理以及如何解析它。你会知道如何构建一个网页并将其提供给浏览器。最后,你会理解当尝试为多个客户端提供服务并处理数百万个请求时,这个架构会变得多么复杂!虽然我不会构建一个能处理数百万个请求的服务器,但希望你能体会到实际为你提供这篇博客文章的服务器的复杂性。

废话不多说,让我们从本系列的第一部分开始:TCP 套接字!

直接开始

使用以下头文件:

#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string>

进入全屏模式 退出全屏模式

创建套接字

为了让服务器能够接受请求并回复响应,我们需要一个服务器和客户端可以连接(互相通信)的媒介。为此,我们使用套接字。套接字可以被看作是在网络上运行的两个程序之间的通信端点。

int server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_fd < 0)
{
    std::cerr << "error creating the socket" << std::endl;
    return -1;
}

进入全屏模式 退出全屏模式

上面的代码创建了一个套接字。AF_INET 表示我们使用 IPv4SOCK_STREAM 表示我们使用 TCPIPPROTO_TCP 表示我们使用 TCP 协议。或者,你也可以将 0 作为最后一个参数传递,内核将根据套接字类型来确定协议(在本例中是 TCP,因为我们使用了 SOCK_STREAM)。

socket 函数返回一个整数文件描述符,我们可以用它来引用这个套接字。可以将文件描述符看作是这个套接字的唯一标识符。如果文件描述符是 -1,则表示创建套接字失败。

我建议在创建套接字后添加以下代码片段:

int opt{1};
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

进入全屏模式 退出全屏模式

当你测试代码时,你可能需要多次启动服务器(就像我做的那样)。但是,如果你不执行上述步骤,你将收到一个错误,告诉你地址已在使用中。这是因为服务器关闭后,套接字会卡在 TIME_WAIT 状态。上述代码可以防止这种情况,并允许你在服务器关闭后立即重用相同的地址。

将套接字绑定到地址

现在我们有了套接字,但它没有连接到任何地址。因此,我们需要将其绑定到一个地址。地址是 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() 函数绑定服务器,该函数接受套接字文件描述符、服务器地址和地址大小作为参数。同样,如果这返回 -1,则表示绑定套接字失败。

监听传入连接

现在服务器已绑定到特定地址,这意味着它有了一个家。但它仍然是关闭的,不接受访客,所以我们需要让它监听传入的连接。

if (listen(server_fd, 10) < 0){
    std::cerr << "error listening on the socket" << std::endl;
    return -1;
}

进入全屏模式 退出全屏模式

listen() 函数告诉内核该套接字是一个被动套接字,它应该被用来接受传入的连接。第二个参数 10 是可以排队的挂起连接的最大数量。如果队列已满,传入的连接将被拒绝。如果这返回 -1,则表示监听套接字失败。

整合

现在我们的服务器已准备就绪,我们可以开始接受连接。但在此之前,我会将上面的所有代码放入一个名为 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 可以理解为客户端套接字的唯一标识符。

客户端和服务器之间的通信

由于我们的服务器正在运行并接受客户端,接下来我们需要接受他们的请求并提供适当的响应。但要做到这一点,我们需要从客户端接收数据并向客户端发送数据。

从客户端接收数据

char buffer[8193]{};
int bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
buffer[bytes] = '\0';

进入全屏模式 退出全屏模式

我们使用 recv() 函数从客户端接收数据。它需要客户端文件描述符来识别我们正在与之通信的客户端,一个用于存储数据的缓冲区,缓冲区的大小,以及最后设置为 0 的标志以使用默认行为:它会阻塞直到接收到数据,从内部套接字缓冲区读取数据,将其复制到我们的缓冲区,并从套接字缓冲区中删除它(以避免将来重复接收)。它返回接收到的字节数,如果失败则返回 -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!,并按回车键。你应该在你的终端中看到相同的文本被打印回来。在你输入后,终端看起来会“卡住”或“等待”;这是正常的,因为连接仍然打开,服务器正在等待更多请求。要关闭连接,请按 Ctrl+C

下一步

在下一篇博客文章中,我将解释解析 HTTP 请求构建 HTTP 响应