C++实现简单的HTTP服务器 前言用 C 写一个 HTTP 服务器核心并不在 HTTP 协议本身而在三件事套接字的生命周期、请求报文的边界、响应报文的完整性。协议文本看起来简单——请求行、若干头部、空行、可选正文——但任何一处边界判断写错都会变成阻塞、截断或者安全问题。本文实现一个单线程、短连接、只处理 GET的教学级服务器目的是把流程讲清楚而不是做一个可直接上线的产品。必须先把边界说清楚。本文代码使用 POSIX 套接字sys/socket.h、netinet/in.h、unistd.h适用于 Linux 与 macOSWindows 需要改用 Winsockwinsock2.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.1HTTP/1.1 200 OK长度Content-Length: 0Content-Length: 26连接控制Connection: closeConnection: close内容类型Accept: text/plainContent-Type: text/plain; charsetutf-8结束标记空行空行需要注意Content-Length是字节数不是字符数。UTF-8 中文一个字符占 3 字节用std::string::size()得到的是字节数正好可以直接用如果按「字符个数」估算客户端会一直等待剩余字节表现为页面卡住。二、套接字流程服务器端的典型调用顺序是socket创建监听套接字setsockopt打开SO_REUSEADDRbind绑定地址与端口listen进入监听状态循环accept取出已建立的连接对每个连接recv/send最后close。SO_REUSEADDR的作用是允许服务器重启后立即复用处于TIME_WAIT的端口否则调试期间经常遇到「Address already in use」。bind的地址用sockaddr_in表示端口必须用htons转成网络字节序IPv4 地址用htonl。绑定INADDR_LOOPBACK表示只监听本机回环适合本地测试绑定INADDR_ANY会监听所有网卡在不可信网络中要谨慎并配合防火墙。// 适用于 C17// 平台Linux/POSIX#include arpa/inet.h#include netinet/in.h#include sys/socket.h#include unistd.h#include cstdint#include iostreamint 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_castsockaddr*(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或者超过预设上限。设置上限是必要的防御——否则一个恶意客户端可以只发头部不发空行让服务器无限增长内存。// 适用于 C17// 平台Linux/POSIX#include sys/socket.h#include sys/types.h#include cstddef#include stringstd::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_caststd::size_t(n));if (data.size() max_bytes) {break; // 防御超大请求头}}return data;}解析请求行可以用std::istringstream它按空白分割正好对应「方法 目标 版本」三段。注意只解析第一行不要把整个报文丢进去——正文里可能有任意字节。解析失败段数不足应返回 400而不是继续处理。// 适用于 C17#include sstream#include stringstruct 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是 C20 之前判断前缀的常用写法等价于「从位置 0 开始查找并命中」。C20 起可以用std::string::starts_with但为了兼容 C17 这里不用。本文的实现只处理GET且只认两个固定路径。这样做是有意的一旦开始按请求路径读文件就必须处理路径穿越../、符号链接、URL 解码、MIME 类型、目录列表等一系列问题。教学示例里最安全的做法是根本不把请求目标拼进文件路径而是用白名单映射到固定响应。四、构造响应与发送响应字符串需要精确控制 CRLF 和长度。可以写一个辅助函数把状态码、原因短语、内容类型和正文拼起来并自动计算Content-Length。发送时同样要循环send可能只发送一部分返回值是实际发送的字节数。// 适用于 C17// 平台Linux/POSIX#include sys/socket.h#include sys/types.h#include cstddef#include stringstd::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_caststd::size_t(n);}return true;}body.size()返回std::size_tstd::to_string有对应的重载。data.data() sent在 C17 中std::string::data()返回可写指针C11 起连续存储C17 起非 const 版本返回char*这里只读没问题。发送失败时直接关闭连接即可不必重试——对端可能已经断开。五、完整示例下面的程序把上面的片段拼成一个可运行的整体监听127.0.0.1:8080对GET /返回一段文本对GET /health返回ok其余返回 404方法不是 GET 返回 405。每个连接只处理一个请求。// 适用于 C17// 平台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 stringstruct 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_castsockaddr*(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_caststd::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_caststd::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; charsetutf-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理解骨架的结构才知道成熟服务器替你处理了哪些细节。