C++手写HTTP GET客户端:socket、超时与chunked解析
发布时间:2026/10/23 19:28:14 锦皓数字建站

简介面向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 的完整链路把整个流程拆成六步每一步都有明确的失败点排查的时候直接对着位置找解析 URL切出 host、port、path。失败点端口没填、路径以//开头、URL 里带空格没转义。调 getaddrinfo 做 DNS 解析拿到 sockaddr 链表。失败点域名写错、本地 DNS 不通、IPv6 优先但目标没 AAAA 记录。建 socket 并 connect。失败点目标端口没开、防火墙拦截、连接超时没设。拼请求报文send 出去。失败点Host 头漏了服务端直接 400只发了一半数据。recv 循环收满响应。失败点只 recv 一次正文少一截。解析状态行、响应头、正文。失败点\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; // 域名或 IPIPv6 不带方括号 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 和 portIPv6 形如 [::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::mapstd::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 程序卡住不返回连错误都没有现象命令行敲下去光标一直闪几分钟都不动CtrlC 才能退出日志里一个字都没有。原因两条路都可能阻塞——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带的是charsetgbk而终端按 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 这些地方反复翻车。从那以后我每次动到请求拼装相关代码都强制把完整报文用十六进制打一遍再走后续流程这个习惯救回来的时间远超它花掉的几秒。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。