ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++手写HTTP GET客户端:socket、超时与chunked解析

C++手写HTTP GET客户端:socket、超时与chunked解析

简介:面向C++网络编程入门者的轻量级HTTP GET请求实现示例,聚焦在Windows环境下从指定URL拉取数据的最小可用路径。代码将请求发起、响应获取与耗时统计拆分组织,借助头文件声明接口、源文件承载连接建立与数据读取逻辑,并以计时器记录请求执行时间,便于理解一次HTTP GET从构造到回收的基本流程,也可作为课程实验或小型工具原型的基础骨架,并保留较清晰的调用关系。压缩包共3个文件,由2个头文件和1个C++源文件组成,整体约2KB,体量精简,适合快速通读与二次修改。目前已有268人学习,说明该主题对初学者具有一定参考价值。读者可借此理解请求封装、响应读取与性能计时,并据此补充超时、重定向、HTTPS与代理等扩展能力,逐步形成更健壮的网络通信模块。

1. 问题往往出在请求根本没发出去

线上排查一个接口问题,服务端日志里干干净净,一条请求都没收到,客户端那边只回了一句“超时”。我翻出很早以前写的一个 http GET C++ 实现,不到三百行,只用系统 socket 和标准库,两分钟就把问题定在了域名解析上。

这个 http GET C++ 实现干的事很单一:给一个 URL,拼出 GET 请求,收完响应,把状态码、响应头和正文交回来。它适合三类人:想搞明白 HTTP 在网络上究竟发了什么字节的初学者;需要在命令行、嵌入式或者离线环境里做最小网络访问的开发者;以及手里有老项目、不想为了一个下载功能就拖进整套网络库的人。

下面按我实际拆它的顺序讲:先看边界在哪,再看目录和编译,然后逐段过核心代码,最后落到怎么把它改成能进项目的组件。

2. 项目定位与技术选型:只用标准库和系统 socket 的最小可用方案

2.1 能力边界先划清楚

这类小项目最怕定位模糊,什么都能干最后什么都干不好。我拆的这个版本把边界压得很死:只支持明文 HTTP/1.1,只支持 GET,一次请求一条连接,不自动跟随重定向,不自动解压 gzip。这些不是能力缺失,是为了让主流程能被完整读完——一个刚接触网络编程的人,顺着一个文件从头读到尾就能跑通。

先把这个版本能做什么、不能做什么列清楚,比后面看代码更重要。很多人在这一步就误解了项目的能力,接上 HTTPS 域名调半天没反应,回头才发现协议压根不对。

能力项当前实现备注
协议HTTP/1.1 明文走 80 端口,不涉及 TLS
方法仅 GET加 POST 只需改请求行和补 body
连接一次一连接,Connection: close不做 keep-alive 连接池
重定向不跟随301/302 直接原样返回
压缩不声明 gzip,收到什么给什么请求头里写 Accept-Encoding: identity
编码原样输出,不做转码中文页面可能是 GBK

依赖部分很关键:Linux 和 macOS 上什么都不用链,POSIX socket 在 libc 里;Windows 上要额外链 ws2_32,并且在程序启动时调一次 WSAStartup、退出时调 WSACleanup,这两步漏了就是一堆找不到符号的链接错误。

2.2 目录结构与构建方式

源文件按职责切了四块,加上一个入口。这样切的好处是每个文件都能单独看懂,改起来也方便:想加 POST 只动 request.cpp,想加 chunked 解码只动 response.cpp。

tiny-http/ ├── include/ │ └── http_client.h # 对外结构体与函数声明 ├── src/ │ ├── url.cpp # URL 拆解:scheme / host / port / path │ ├── socket.cpp # DNS 解析、带超时的 connect │ ├── request.cpp # 拼装请求报文并发送 │ ├── response.cpp # 收数据、解析状态行与响应头、chunked 解码 │ └── main.cpp # 命令行入口 ├── CMakeLists.txt └── README.md

对应这份结构的 CMakeLists.txt 我一般这么写,静态库加可执行文件两段,跨平台时在 Windows 分支补链接项:

cmake_minimum_required(VERSION 3.10) project(tiny_http CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(tinyhttp STATIC src/url.cpp src/socket.cpp src/request.cpp src/response.cpp ) target_include_directories(tinyhttp PUBLIC include) add_executable(httpget src/main.cpp) target_link_libraries(httpget PRIVATE tinyhttp) # Windows 下 socket 相关符号在 ws2_32 里,不链必然报未定义引用 if (WIN32) target_link_libraries(tinyhttp PUBLIC ws2_32) endif()

add_library(... STATIC)把四个实现文件打成一个静态库,target_include_directories用 PUBLIC 把 include 目录透出去,后面加别的可执行文件(比如测试程序)时不用重复写路径。编译就两条命令,cmake -B build然后cmake --build build,产物在 build 目录下的 httpget。

2.3 一次 GET 的完整链路

把整个流程拆成六步,每一步都有明确的失败点,排查的时候直接对着位置找:

  1. 解析 URL,切出 host、port、path。失败点:端口没填、路径以//开头、URL 里带空格没转义。
  2. 调 getaddrinfo 做 DNS 解析,拿到 sockaddr 链表。失败点:域名写错、本地 DNS 不通、IPv6 优先但目标没 AAAA 记录。
  3. 建 socket 并 connect。失败点:目标端口没开、防火墙拦截、连接超时没设。
  4. 拼请求报文,send 出去。失败点:Host 头漏了,服务端直接 400;只发了一半数据。
  5. recv 循环收满响应。失败点:只 recv 一次,正文少一截。
  6. 解析状态行、响应头、正文。失败点:\r\n处理不干净,头部键大小写没归一。

这六步里,前两步占了我遇到问题的绝大多数。尤其是第二步,DNS 解析是同步阻塞的,没有超时参数,网络环境一差就卡在那儿,进程看着像死了,其实是卡在 getaddrinfo 里面——这是真正的黑匣子环节,日志里什么都看不到。

3. 核心代码拆解:从 URL 解析到 socket 连接

3.1 URL 解析:把字符串切成四段

一切从字符串开始。这段代码看着简单,实际上 IPv6 地址和端口缺省这两个坑能把人折腾很久,我写的时候专门把方括号分支留了出来。

#include <string> #include <cctype> #include <cstdlib> struct Url { std::string scheme; // http std::string host; // 域名或 IP,IPv6 不带方括号 std::string port; // 缺省时按 scheme 补 80/443 std::string path; // 含查询串,至少是 "/" }; static bool parse_url(const std::string& raw, Url& out) { std::string s = raw; // 1. 拆 scheme,没有就按 http 处理 auto pos = s.find("://"); if (pos != std::string::npos) { out.scheme = s.substr(0, pos); for (auto& c : out.scheme) c = (char)std::tolower(c); s = s.substr(pos + 3); } else { out.scheme = "http"; } // 2. 第一个 '/' 之后全是路径,没写就补 "/" auto slash = s.find('/'); std::string hostport = (slash == std::string::npos) ? s : s.substr(0, slash); out.path = (slash == std::string::npos) ? "/" : s.substr(slash); // 3. 拆 host 和 port,IPv6 形如 [::1]:8080 if (!hostport.empty() && hostport[0] == '[') { auto rb = hostport.find(']'); if (rb == std::string::npos) return false; out.host = hostport.substr(1, rb - 1); if (rb + 1 < hostport.size() && hostport[rb + 1] == ':') out.port = hostport.substr(rb + 2); } else { auto colon = hostport.rfind(':'); if (colon != std::string::npos) { out.host = hostport.substr(0, colon); out.port = hostport.substr(colon + 1); } else { out.host = hostport; } } if (out.host.empty()) return false; if (out.port.empty()) out.port = (out.scheme == "https") ? "443" : "80"; return true; }

几个点值得单独说。path在没写的时候必须补成/,因为 HTTP 请求行里GET HTTP/1.1是非法请求,很多服务端直接回 400,我第一次跑就栽在这儿。IPv6 分支里我存的是去掉方括号的地址,因为 getaddrinfo 认的是::1而不是[::1]。端口用rfind而不是find,是为了兼容host:port后面还可能跟别的东西的情况,虽然这个版本用不上,但改成rfind不增加成本。

参数out是引用输出,函数只返回成功与否,这样调用侧不需要处理指针为空,也不用考虑异常。返回值 false 的三种情况——方括号没闭合、host 为空、整体解析不出来——在 main 里统一打一行 usage 提示就够了。

3.2 DNS 解析与连接:超时是必须的

getaddrinfo 一次拿全地址族和端口,比自己调 gethostbyname 再拼 sockaddr_in 靠谱得多,而且后者在现代代码里已经不该出现。连接超时我没用SO_SNDTIMEO,那玩意儿对 connect 不生效,正确做法是把 socket 设成非阻塞、connect 之后用 select 等可写。

#include <sys/socket.h> #include <netdb.h> #include <unistd.h> #include <fcntl.h> #include <sys/select.h> #include <cerrno> #include <cstring> int connect_with_timeout(const std::string& host, const std::string& port, int timeout_ms, std::string& err) { struct addrinfo hints; std::memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; // IPv4 / IPv6 都接受 hints.ai_socktype = SOCK_STREAM; // TCP struct addrinfo* res = nullptr; int rc = getaddrinfo(host.c_str(), port.c_str(), &hints, &res); if (rc != 0) { err = gai_strerror(rc); return -1; } int fd = -1; for (auto p = res; p != nullptr; p = p->ai_next) { fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (fd < 0) continue; int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); rc = connect(fd, p->ai_addr, p->ai_addrlen); if (rc == 0) { fcntl(fd, F_SETFL, flags); break; } // 本机地址,立即成功 if (errno != EINPROGRESS) { close(fd); fd = -1; continue; } fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; rc = select(fd + 1, nullptr, &wfds, nullptr, &tv); if (rc > 0) { int soerr = 0; socklen_t len = sizeof(soerr); getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &len); if (soerr == 0) { fcntl(fd, F_SETFL, flags); break; } // 恢复阻塞 } close(fd); fd = -1; } freeaddrinfo(res); // addrinfo 必须配对释放 if (fd < 0) err = "connect failed or timeout"; return fd; }

hints.ai_family写AF_UNSPEC让系统自己决定走 IPv4 还是 IPv6,用AF_INET硬编码在纯 IPv6 环境里直接连不上。循环遍历ai_next是重点:一个域名可能解析出多个地址,第一个不通要试第二个,直接取res头节点是新手常见写法。

select返回大于 0 只说明“有事件”,不代表连接成功,必须用getsockopt(SO_ERROR)再确认一次,这一步漏了会出现“连接明明失败但程序往下走”的诡异现象。连接成功后把O_NONBLOCK恢复掉,后面 recv 用阻塞模式,逻辑简单。timeout_ms我一般设 5000,内网环境可以降到 1000。

3.3 拼请求报文:CRLF、Host 头、Connection

请求报文就是一段字符串,但每个换行必须是\r\n两个字节,少一个\r服务端就可能把整个请求当非法处理。

std::string build_request(const Url& u) { std::string req; req += "GET " + u.path + " HTTP/1.1\r\n"; req += "Host: " + u.host + "\r\n"; req += "User-Agent: tiny-http/1.0\r\n"; req += "Accept: */*\r\n"; req += "Accept-Encoding: identity\r\n"; // 明确不要压缩,省掉解压逻辑 req += "Connection: close\r\n"; // 服务端回完即关,读起来不用判长度 req += "\r\n"; // 空行,报文结束标志 return req; }

Host在 HTTP/1.1 里是必填项,虚拟主机靠它区分后端站点,漏了大概率回 400。Accept-Encoding: identity是我故意的:不声明 gzip,服务端就不会压,客户端也就不需要链接解压库,整个依赖又少一层。Connection: close让服务端发完就关连接,recv 读到 0 就代表结束,不用自己算 Content-Length,是这类小工具最省心的写法。

发送时有个细节:send的返回值可能小于请求长度,尤其是请求头很大或者网络拥塞时。小请求一般一次发完,但我习惯写成循环,避免偶发的半包问题:

size_t sent = 0; while (sent < req.size()) { ssize_t n = send(fd, req.data() + sent, req.size() - sent, 0); if (n <= 0) { /* 记录错误并退出 */ break; } sent += (size_t)n; }

4. 响应读取与解析:状态行、头部、正文三段式

4.1 收数据:recv 循环与内存上限

一次 recv 拿到的可能只是响应头的一部分,也可能是整个响应加后半截正文,绝不能假设一次收完。写法就是循环拼接到对端关闭为止,同时加一个上限,防止碰上超大文件把内存吃光。

std::string recv_all(int fd, size_t max_bytes = 8 * 1024 * 1024) { std::string buf; char tmp[8192]; while (true) { ssize_t n = recv(fd, tmp, sizeof(tmp), 0); if (n > 0) { buf.append(tmp, (size_t)n); if (buf.size() > max_bytes) break; // 保护性截断 } else if (n == 0) { break; // 对端关闭,正常结束 } else { if (errno == EINTR) continue; // 被信号打断,重试 break; // 超时或真错误 } } return buf; }

缓冲区 8192 是个经验值,太小系统调用次数多,太大栈上占用高。EINTR那行必须留着,某些环境下信号会打断 recv,不重试就会拿到半截数据还以为收完了。max_bytes默认 8MB,超过就停,返回的内容虽然不完整但程序不会崩——下载大文件应该走边收边写的路子,不该用这个函数。

另外,如果 socket 上设了SO_RCVTIMEO,超时后 recv 返回 -1 且 errno 为EAGAIN或EWOULDBLOCK,这里会走到 break,调用方看到的是“短响应”,需要自己根据状态判断是超时还是正常结束。

4.2 解析状态行与响应头

响应分成三段:状态行、若干头部行、空行之后的正文。用\r\n\r\n定位头部结束位置是最稳的做法,比逐行扫描简单。

#include <map> #include <sstream> #include <algorithm> struct Response { int status = 0; std::string reason; std::map<std::string, std::string> headers; // 键统一转小写 std::string body; }; static std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); return s; } static std::string trim(const std::string& s) { size_t b = s.find_first_not_of(" \t"); if (b == std::string::npos) return ""; size_t e = s.find_last_not_of(" \t\r"); return s.substr(b, e - b + 1); } bool parse_response(const std::string& raw, Response& resp) { auto head_end = raw.find("\r\n\r\n"); if (head_end == std::string::npos) return false; // 响应头都不完整 std::istringstream ss(raw.substr(0, head_end)); std::string line; std::getline(ss, line); // 状态行 if (!line.empty() && line.back() == '\r') line.pop_back(); auto sp1 = line.find(' '); // HTTP/1.1 200 OK if (sp1 == std::string::npos) return false; auto sp2 = line.find(' ', sp1 + 1); resp.status = std::atoi(line.substr(sp1 + 1, sp2 - sp1 - 1).c_str()); if (sp2 != std::string::npos) resp.reason = line.substr(sp2 + 1); while (std::getline(ss, line)) { // 逐个头字段 if (!line.empty() && line.back() == '\r') line.pop_back(); if (line.empty()) continue; auto colon = line.find(':'); if (colon == std::string::npos) continue; resp.headers[to_lower(trim(line.substr(0, colon)))] = trim(line.substr(colon + 1)); } resp.body = raw.substr(head_end + 4); // 剩下的就是正文 return true; }

头部键统一转小写是关键设计。RFC 里头部名不区分大小写,实际服务端有的发Content-Type,有的发content-type,不归一的话查询会落空。trim里顺带把\r也去掉,因为某些 getline 实现会把\r留在行尾。

resp.body这里直接取剩余全部字节,前提是用了Connection: close。如果以后改成 keep-alive,这里就得改成按Content-Length精确切,否则会把下一个响应也吃进来。

4.3 正文边界:Content-Length 与 chunked

大部分响应带Content-Length,但有一类响应会带Transfer-Encoding: chunked,正文被切成一块块,每块前面是十六进制的长度。不处理的话,输出里会混进1a2b\r\n这种字符,这是最容易被误判成“乱码”的坑。

// in: 原始正文(含 chunk 头),out: 解码后的净正文 bool dechunk(const std::string& in, std::string& out) { size_t pos = 0; out.clear(); while (pos < in.size()) { auto eol = in.find("\r\n", pos); if (eol == std::string::npos) return false; std::string size_line = in.substr(pos, eol - pos); auto semi = size_line.find(';'); // 可能有 chunk 扩展参数 if (semi != std::string::npos) size_line = size_line.substr(0, semi); size_t chunk_size = std::strtoul(size_line.c_str(), nullptr, 16); pos = eol + 2; if (chunk_size == 0) return true; // 结束块 if (pos + chunk_size > in.size()) return false; // 数据还没收全 out.append(in, pos, chunk_size); pos += chunk_size + 2; // 跳过数据尾巴的 \r\n } return false; }

strtoul第三个参数写 16 表示按十六进制解析,长度字段本来就是 hex。返回 false 有两种含义:数据没收全,或者格式不对,调用侧可以选择再 recv 一轮或者直接报错。结束块之后理论上还可能跟 trailer 头,这个版本直接忽略,够用。

调用顺序很讲究:先解析头部,再判断Transfer-Encoding是否含chunked,含才走解码,否则原样输出。判断时要把值转小写再find,因为有的服务端写Chunked。

5. 排查清单:把程序跑通前大概率会遇到的五个坑

5.1 程序卡住不返回,连错误都没有

现象:命令行敲下去,光标一直闪,几分钟都不动,Ctrl+C 才能退出,日志里一个字都没有。

原因:两条路都可能阻塞——getaddrinfo 不受任何超时控制,DNS 服务器不通就会卡在这儿;connect 在默认阻塞模式下,目标不可达时会等系统级超时,动辄一两个分钟。这两个位置都不打日志,看上去就是“死机”。

解决:连接超时按 3.2 节用非阻塞加 select 实现,日志在解析前后各打一行,就能立刻看出卡在哪一步。getaddrinfo 本身没法设超时,工程上的做法是把它扔到独立线程里,主线程等结果超过阈值就放弃——虽然线程收不回来,但至少主流程能继续。

5.2 服务端返回 400,但请求看起来没问题

现象:自己拼的请求发出去,回来的状态码是 400,把同样的 URL 丢浏览器里完全正常。

原因:九成是Host头缺失,或者请求行里的路径没写全。HTTP/1.1 强制要求 Host,虚拟主机环境下没有 Host 服务端不知道该给哪个站点。另一种情况是路径写成了空字符串,请求行变成GET HTTP/1.1,两个空格连着,解析直接失败。

解决:把实际发出的报文用十六进制方式打出来看,重点检查每一行结尾是不是\r\n两个字节、空行有没有、Host 值是不是纯域名(不带http://前缀和端口以外的多余字符)。

5.3 正文比预期少一截,或者末尾混进奇怪字符

现象:抓下来的 HTML 尾部缺了几个标签,或者正文里出现3f\r\n这类十六进制字符串。

原因:前者是只调用了一次 recv,拿到的只是第一段数据;后者是响应用了 chunked 编码但没解码,长度行被当成正文输出了。

解决:recv 必须循环到返回 0,同时看响应头里有没有Transfer-Encoding: chunked。加日志把每次 recv 的字节数和累计长度打出来,一眼就能看出是提前退出还是解码漏了。

5.4 编译过了,链接阶段一堆未定义符号

现象:Linux 下编译运行正常,拿到 Windows 上编译通过,链接报找不到socket、connect、getaddrinfo之类。

原因:Windows 的 socket API 在 ws2_32 库和 winsock2.h 里,和 POSIX 不是一套;而且进程启动时必须调WSAStartup,否则所有 socket 调用直接失败。

解决:头文件包含改成条件编译,Windows 走winsock2.h并链ws2_32,同时在 main 开头调WSAStartup(MAKEWORD(2, 2), &wsa),退出前调WSACleanup()。注意包含顺序,winsock2.h必须在windows.h前面,否则会带进老的 winsock1 定义。

5.5 中文页面抓下来是乱码

现象:英文页面一切正常,中文页面输出成一片问号或者奇怪的符号组合。

原因:这个实现把字节原样输出,不做任何转码。如果响应头里Content-Type带的是charset=gbk,而终端按 UTF-8 显示,就必然乱码。

解决:先看Content-Type里的 charset,再决定怎么处理。最省事的做法是在请求头里加Accept-Language并优先挑 UTF-8 的站点;真要做转换,就得引入编码转换库,那已经超出这个项目的最小定位了,我一般建议在调用侧解决。

6. 二次开发:把它收敛成一个能进项目的类

单文件 main 跑通之后,下一步一定是包装。我的做法是把 fd 的生命周期收进 RAII 类里,外面只看到一个get(url)接口,错误用返回值加错误串往外抛,不折腾异常。

class HttpClient { public: explicit HttpClient(int timeout_ms = 5000) : timeout_ms_(timeout_ms) {} bool get(const std::string& url, Response& resp, std::string& err) { Url u; if (!parse_url(url, u)) { err = "bad url"; return false; } if (u.scheme != "http") { err = "scheme not supported"; return false; } int fd = connect_with_timeout(u.host, u.port, timeout_ms_, err); if (fd < 0) return false; bool ok = false; std::string req = build_request(u); if (send_all(fd, req)) { // 内部循环处理短写 std::string raw = recv_all(fd); ok = parse_response(raw, resp); if (ok && is_chunked(resp)) ok = dechunk(resp.body, resp.body); // 成功后原地替换 } else { err = "send failed"; } close(fd); // 析构前统一关闭 return ok; } private: int timeout_ms_; };

封装时有三个点别省。第一,close(fd)一定要在所有分支上都覆盖到,我见过太多在错误分支直接 return 导致句柄泄漏的代码,跑久了文件描述符耗尽,报的错还跟网络毫无关系。第二,send_all内部循环处理短写,把这段从业务逻辑里挪出去。第三,dechunk支持原地替换时要小心实现,简单起见可以先解到临时串再 swap。

改完之后验证方法很朴素:拿几个已知行为的地址跑一遍——一个返回固定长度正文的、一个返回 chunked 的、一个会 302 跳转的、一个不存在的域名。四个用例覆盖了正常路径和三类异常,每次改动后全跑一遍,比盯着代码看有效得多。扩展路线按需要挑,别一次全加:

需求改动位置大致工作量
支持 POSTrequest.cpp 加请求行和 Content-Length半小时
自动跟随 302外层循环,最多跟 5 跳一小时
支持 HTTPS连接层换成 TLS 握手,端口改 443半天起
大文件下载recv 改成边收边写文件一小时
连接复用加连接池,解析改按 Content-Length 切一天

最后说个血泪经验。这套代码我前后改过三四版,每次都是图快,跳过“把请求报文原样打出来看一眼”这一步,结果在 Host 头、路径斜杠、CRLF 这些地方反复翻车。从那以后我每次动到请求拼装相关代码,都强制把完整报文用十六进制打一遍再走后续流程,这个习惯救回来的时间远超它花掉的几秒。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进