简介
在本系列中,我将介绍如何使用 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 表示我们使用 IPv4。SOCK_STREAM 表示我们使用 TCP。IPPROTO_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 表示我们使用 IPv4。sin_port = htons(8080) 表示我们使用端口 8080。htons() 用于将端口号转换为网络字节序。
我们使用 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 结构体中。如果成功,它返回客户端文件描述符,如果失败则返回 -1。client_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(),将 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!,并按回车键。你应该在你的终端中看到相同的文本被打印回来。在你输入后,终端看起来会“卡住”或“等待”;这是正常的,因为连接仍然打开,服务器正在等待更多请求。要关闭连接,请按 Ctrl+C。
下一步
在下一篇博客文章中,我将解释解析 HTTP 请求和构建 HTTP 响应。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.