前言
用 C++ 写一个 HTTP 服务器,核心并不在 HTTP 协议本身,而在三件事:套接字的生命周期、请求报文的边界、响应报文的完整性。协议文本看起来简单——请求行、若干头部、空行、可选正文——但任何一处边界判断写错,都会变成阻塞、截断或者安全问题。本文实现一个单线程、短连接、只处理 GET的教学级服务器,目的是把流程讲清楚,而不是做一个可直接上线的产品。
必须先把边界说清楚。本文代码使用 POSIX 套接字(<sys/socket.h>、<netinet/in.h>、<unistd.h>),适用于 Linux 与 macOS;Windows 需要改用 Winsock(<winsock2.h>、WSAStartup、closesocket),接口不同。实现不支持 HTTPS、不支持 HTTP/2、不支持分块传输编码(chunked)、不支持并发连接、不做完整的头部解析。生产环境应使用成熟服务器或 Asio 一类库,而不是从零手写。
另一个常见误解是「HTTP 服务器就是recv之后send一段字符串」。实际上 TCP 是字节流,不保证一次recv收到完整请求,也不保证一次send发出完整响应;必须循环读写,并按\r\n\r\n定位头部结束。本文的示例会把这些点逐条落实。
一、HTTP 请求与响应的最小结构
一个 HTTP/1.1 请求由请求行、头部字段、空行和可选正文组成,行分隔符是 CRLF(\r\n)。请求行格式为「方法 目标 版本」,例如GET /index.html HTTP/1.1。头部是「字段名: 字段值」,字段名大小写不敏感。空行(连续两个 CRLF)之后是正文;正文长度由Content-Length或Transfer-Encoding决定。
响应结构类似:状态行「版本 状态码 原因短语」,若干头部,空行,正文。要让客户端正确判断响应结束,至少要做对一件事:给出准确的Content-Length,或者在短连接下发送完毕后关闭套接字。本文选择后者加前者:既写Content-Length,也写Connection: close,每次处理一个请求就关闭连接。
| 要素 | 请求中的样子 | 响应中的样子 |
|---|
| 起始行 | GET / HTTP/1.1 | HTTP/1.1 200 OK |
| 长度 | Content-Length: 0 | Content-Length: 26 |
| 连接控制 | Connection: close | Connection: close |
| 内容类型 | Accept: text/plain | Content-Type: text/plain; charset=utf-8 |
| 结束标记 | 空行 | 空行 |
需要注意,Content-Length是字节数,不是字符数。UTF-8 中文一个字符占 3 字节,用std::string::size()得到的是字节数,正好可以直接用;如果按「字符个数」估算,客户端会一直等待剩余字节,表现为页面卡住。
二、套接字流程
服务器端的典型调用顺序是:socket创建监听套接字,setsockopt打开SO_REUSEADDR,bind绑定地址与端口,listen进入监听状态,循环accept取出已建立的连接,对每个连接recv/send,最后close。
SO_REUSEADDR的作用是允许服务器重启后立即复用处于TIME_WAIT的端口,否则调试期间经常遇到「Address already in use」。bind的地址用sockaddr_in表示,端口必须用htons转成网络字节序,IPv4 地址用htonl。绑定INADDR_LOOPBACK表示只监听本机回环,适合本地测试;绑定INADDR_ANY会监听所有网卡,在不可信网络中要谨慎,并配合防火墙。
// 适用于 C++17
// 平台:Linux/POSIX
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdint>
#include <iostream>
int create_listener(std::uint16_t port) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return -1;
}
int yes = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 仅本机
addr.sin_port = htons(port);
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
::close(fd);
return -1;
}
if (::listen(fd, SOMAXCONN) < 0) {
::close(fd);
return -1;
}
return fd;
}accept返回的是一个新的已连接套接字,监听套接字继续用于接受后续连接;两者都要close。accept可能被信号中断并返回-1,此时errno == EINTR,应当重试而不是直接退出。recv返回 0 表示对端正常关闭,返回负值表示错误,这两种情况都要结束当前连接。
三、读取与解析请求
TCP 不保留消息边界,一次recv可能只拿到请求的一部分。最简单可靠的判断方式是:不断追加到std::string,直到找到\r\n\r\n,或者超过预设上限。设置上限是必要的防御——否则一个恶意客户端可以只发头部不发空行,让服务器无限增长内存。
// 适用于 C++17
// 平台:Linux/POSIX
#include <sys/socket.h>
#include <sys/types.h>
#include <cstddef>
#include <string>
std::string read_request_head(int fd, std::size_t max_bytes = 64 * 1024) {
std::string data;
char buffer[4096];
while (data.find("\r\n\r\n") == std::string::npos) {
const ssize_t n = ::recv(fd, buffer, sizeof(buffer), 0);
if (n <= 0) {
break; // 对端关闭或出错
}
data.append(buffer, static_cast<std::size_t>(n));
if (data.size() > max_bytes) {
break; // 防御超大请求头
}
}
return data;
}解析请求行可以用std::istringstream,它按空白分割,正好对应「方法 目标 版本」三段。注意只解析第一行,不要把整个报文丢进去——正文里可能有任意字节。解析失败(段数不足)应返回 400,而不是继续处理。
// 适用于 C++17
#include <sstream>
#include <string>
struct Request {
std::string method;
std::string target;
std::string version;
};
bool parse_request_line(const std::string& raw, Request& req) {
const std::size_t line_end = raw.find("\r\n");
if (line_end == std::string::npos) {
return false;
}
std::istringstream iss(raw.substr(0, line_end));
if (!(iss >> req.method >> req.target >> req.version)) {
return false;
}
return req.version.rfind("HTTP/", 0) == 0;
}rfind("HTTP/", 0) == 0是 C++20 之前判断前缀的常用写法,等价于「从位置 0 开始查找并命中」。C++20 起可以用std::string::starts_with,但为了兼容 C++17 这里不用。
本文的实现只处理GET,且只认两个固定路径。这样做是有意的:一旦开始按请求路径读文件,就必须处理路径穿越(../)、符号链接、URL 解码、MIME 类型、目录列表等一系列问题。教学示例里最安全的做法是根本不把请求目标拼进文件路径,而是用白名单映射到固定响应。
四、构造响应与发送
响应字符串需要精确控制 CRLF 和长度。可以写一个辅助函数,把状态码、原因短语、内容类型和正文拼起来,并自动计算Content-Length。发送时同样要循环:send可能只发送一部分,返回值是实际发送的字节数。
// 适用于 C++17
// 平台:Linux/POSIX
#include <sys/socket.h>
#include <sys/types.h>
#include <cstddef>
#include <string>
std::string build_response(int status, const std::string& reason,
const std::string& content_type,
const std::string& body) {
std::string response;
response += "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n";
response += "Content-Type: " + content_type + "\r\n";
response += "Content-Length: " + std::to_string(body.size()) + "\r\n";
response += "Connection: close\r\n";
response += "\r\n";
response += body;
return response;
}
bool send_all(int fd, const std::string& data) {
std::size_t sent = 0;
while (sent < data.size()) {
const ssize_t n = ::send(fd, data.data() + sent,
data.size() - sent, 0);
if (n <= 0) {
return false;
}
sent += static_cast<std::size_t>(n);
}
return true;
}body.size()返回std::size_t,std::to_string有对应的重载。data.data() + sent在 C++17 中std::string::data()返回可写指针(C++11 起连续存储,C++17 起非 const 版本返回char*),这里只读,没问题。发送失败时直接关闭连接即可,不必重试——对端可能已经断开。
五、完整示例
下面的程序把上面的片段拼成一个可运行的整体:监听127.0.0.1:8080,对GET /返回一段文本,对GET /health返回ok,其余返回 404,方法不是 GET 返回 405。每个连接只处理一个请求。
// 适用于 C++17
// 平台:Linux/POSIX
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <sstream>
#include <string>
struct Request {
std::string method;
std::string target;
std::string version;
};
int create_listener(std::uint16_t port) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
int yes = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(port);
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
::close(fd);
return -1;
}
if (::listen(fd, SOMAXCONN) < 0) {
::close(fd);
return -1;
}
return fd;
}
std::string read_request_head(int fd, std::size_t max_bytes = 64 * 1024) {
std::string data;
char buffer[4096];
while (data.find("\r\n\r\n") == std::string::npos) {
const ssize_t n = ::recv(fd, buffer, sizeof(buffer), 0);
if (n <= 0) break;
data.append(buffer, static_cast<std::size_t>(n));
if (data.size() > max_bytes) break;
}
return data;
}
bool parse_request_line(const std::string& raw, Request& req) {
const std::size_t line_end = raw.find("\r\n");
if (line_end == std::string::npos) return false;
std::istringstream iss(raw.substr(0, line_end));
if (!(iss >> req.method >> req.target >> req.version)) return false;
return req.version.rfind("HTTP/", 0) == 0;
}
std::string build_response(int status, const std::string& reason,
const std::string& content_type,
const std::string& body) {
std::string response;
response += "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n";
response += "Content-Type: " + content_type + "\r\n";
response += "Content-Length: " + std::to_string(body.size()) + "\r\n";
response += "Connection: close\r\n\r\n";
response += body;
return response;
}
bool send_all(int fd, const std::string& data) {
std::size_t sent = 0;
while (sent < data.size()) {
const ssize_t n = ::send(fd, data.data() + sent, data.size() - sent, 0);
if (n <= 0) return false;
sent += static_cast<std::size_t>(n);
}
return true;
}
int main() {
const std::uint16_t port = 8080;
const int listener = create_listener(port);
if (listener < 0) {
std::cerr << "failed to listen\n";
return 1;
}
std::cout << "listening on 127.0.0.1:" << port << '\n';
for (;;) {
const int client = ::accept(listener, nullptr, nullptr);
if (client < 0) {
if (errno == EINTR) continue;
break;
}
const std::string raw = read_request_head(client);
Request req;
int status = 400;
std::string reason = "Bad Request";
std::string body = "bad request\n";
if (parse_request_line(raw, req)) {
if (req.method != "GET") {
status = 405; reason = "Method Not Allowed";
body = "method not allowed\n";
} else if (req.target == "/") {
status = 200; reason = "OK";
body = "hello from a tiny C++ server\n";
} else if (req.target == "/health") {
status = 200; reason = "OK";
body = "ok\n";
} else {
status = 404; reason = "Not Found";
body = "not found\n";
}
}
const std::string response =
build_response(status, reason, "text/plain; charset=utf-8", body);
send_all(client, response);
::close(client);
}
::close(listener);
return 0;
}这个程序有几个明确的限制:单线程,一个慢客户端会阻塞后续所有连接;没有超时,recv可能永久阻塞;没有读取正文,所以只适合无正文的 GET;没有处理Expect: 100-continue;没有日志与信号处理。它适合学习流程和本地实验,不适合暴露到公网。
如果确实需要按路径提供文件,防御措施至少包括:对 URL 做百分号解码后再校验;拒绝包含..的路径段;把根目录规范化后,确认最终路径仍以根目录为前缀;不要跟随符号链接;设置大小上限。更稳妥的做法是用白名单,把路径映射到固定的资源 ID,而不是直接拼文件系统路径。
常见坑点
- ❌ 一次
recv就把缓冲区当成完整请求解析。✅ 循环读取直到出现\r\n\r\n,并设置最大长度上限。
- ❌ 用
send一次就认为响应发送完毕。✅ 循环发送直到全部字节写出,处理部分发送与EINTR。
- ❌ 只写
Content-Length却按字符数计算中文长度。✅ 用std::string::size()的字节数,它与 HTTP 的长度定义一致。
- ❌ 把请求目标直接拼进文件路径,导致
../路径穿越。✅ 用白名单路由;必须读文件时做规范化与前缀校验,并拒绝可疑路径。
- ❌ 绑定
INADDR_ANY后在公网环境测试,未加任何访问控制。✅ 本地开发绑定INADDR_LOOPBACK;对外提供服务时配置防火墙与反向代理。
- ❌
accept返回-1就直接退出循环。✅ 区分EINTR(重试)与其他错误(记录后退出)。
- ❌ 忘记
close客户端套接字,文件描述符泄漏,最终accept失败。✅ 每个连接处理完立即close;用 RAII 包装更稳妥。
- ❌ 在 HTTP 响应里回显未经转义的请求内容,造成反射型 XSS。✅ 对输出做 HTML 转义,或只返回固定文本;设置正确的
Content-Type。
总结
| 环节 | 关键点 |
|---|
| 监听 | socket→SO_REUSEADDR→bind→listen |
| 接受 | accept返回新套接字;处理EINTR |
| 读取 | 循环recv,以\r\n\r\n定位头部结束,设上限 |
| 解析 | 只解析请求行;方法、目标、版本分别校验 |
| 响应 | 状态行 + 头部 + 空行 + 正文,Content-Length用字节数 |
| 发送 | 循环send,处理部分写 |
| 安全 | 白名单路由、绑定回环、输出转义、限制请求大小 |
一个「简单」的 HTTP 服务器,难点不在协议文本,而在字节流边界、长度计算和资源释放这三件底层事情。把示例跑通之后,下一步应当考虑超时、并发模型与真正的 HTTP 解析库,而不是在这个骨架上不断堆if;理解骨架的结构,才知道成熟服务器替你处理了哪些细节。