C++手写HTTP GET请求:从socket到响应解析的完整实现
简介这份资源面向具备一定C基础、希望理解网络通信底层原理的开发者提供了一套在Windows环境下实现简单HTTP GET请求的完整示例代码。资源包共3个文件包含2个.h头文件与1个.cpp源文件压缩包体积约2KB其中头文件分别承担HTTP请求类的接口定义与计时器类的声明源文件则给出请求执行与响应处理的具体实现。代码将HTTP请求逻辑与耗时统计相结合借助计时器测量请求执行时间便于进行性能分析与调试。目前已有268人学习下载。通过这份资源读者可以掌握从指定URL获取数据的基本流程理解请求类与计时器类的协作方式并以此为基础扩展HTTPS支持、超时重定向处理、代理配置等更复杂的网络功能是入门C网络编程与HTTP协议实践的轻量起点。1. 从零手搓 HTTP GET一份能直接编译的 C 网络请求实现很多人第一次用 C 发 HTTP 请求第一反应是找 libcurl结果在 Windows 上折腾链接库、在 Linux 上纠结 OpenSSL 版本半天跑不起来。其实如果只是发一个最朴素的 GET 请求、拿回响应体用标准 socket 手写一遍反而更快、更透明也更能看清 HTTP 协议到底长什么样。这份资源就是一份极简的 C HTTP GET 实现不依赖任何第三方网络库纯 POSIX socket 加字符串解析编译即用。它适合两类人一是刚学完 socket 编程、想找个真实协议练手的同学二是需要在嵌入式或受限环境里发个请求、又不想引入重型依赖的工程师。下面我按“协议长什么样 → 代码怎么搭 → 怎么编译跑通 → 坑在哪”的顺序拆一遍。2. HTTP GET 的报文结构请求行、请求头、空行到底怎么拼2.1 一个 GET 请求在字节层面是什么HTTP 是文本协议GET 请求的原始形态就是几行 ASCII 文本加一个空行。一个最小可用的请求长这样GET /index.html HTTP/1.1\r\n Host: example.com\r\n Connection: close\r\n \r\n拆开看第一行是请求行由方法、请求目标、协议版本三部分组成中间用空格分隔。第二行开始是请求头每行一个Key: Value对。最后必须有一个单独的空行也就是连续两个\r\n它告诉服务器“请求头到此结束”。很多人第一次手写 HTTP 失败就是漏了这个空行服务器一直等后续数据客户端一直等响应双方僵住直到超时。这里有两个容易忽略的点。第一行尾必须是\r\n而不是\n。虽然不少服务器对\n也能容忍但严格按 RFC 7230 应该用 CRLF自己拼报文时别偷懒。第二Host头在 HTTP/1.1 里是强制的因为同一台 IP 上可能挂多个虚拟主机服务器靠 Host 决定给你哪个站点。少了它很多服务器直接返回 400。2.2 为什么用 Connection: close 而不是 keep-aliveHTTP/1.1 默认是持久连接响应发完连接不关等着你发下一个请求。这对浏览器是好事但对我们这种“发一个请求就退出”的小程序反而是麻烦你得自己判断响应体什么时候结束。判断响应体长度有两种方式一是看Content-Length头二是看Transfer-Encoding: chunked分块编码。分块编码的解析要复杂不少。所以这份实现里我一般会主动加Connection: close让服务器在响应发完后关闭连接。这样客户端就可以用“一直 recv 到返回 0”的方式收完所有数据不用去解析长度。代价是每次请求都要重新建连但对单次请求场景完全无所谓。这是新手最容易上手、也最不容易翻车的选择。2.3 响应报文的结构与状态行服务器返回的也是文本加正文。第一行是状态行形如HTTP/1.1 200 OK中间那个三位数字就是状态码。之后是响应头同样以空行结束空行之后是响应体。我们要提取的核心信息就两样状态码和响应体。解析时有个细节响应头和响应体之间也是\r\n\r\n分隔但响应体本身可能包含任意字节包括看起来像\r\n\r\n的内容。所以正确做法是先找到第一个\r\n\r\n它之前是头部之后全部是正文不能拿整个缓冲区去反复查找分隔符。这个边界处理错了遇到二进制内容就会截断。3. 用 socket 手写请求从 getaddrinfo 到 recv 循环3.1 建立连接getaddrinfo 比 gethostbyname 更靠谱老教程里常见gethostbyname但它不支持 IPv6也不是线程安全的。现在更推荐getaddrinfo它能同时处理 IPv4/IPv6还能直接填好 sockaddr 结构。下面这段是建连的核心// 解析域名拿到可用的地址列表 struct addrinfo hints{}, *res nullptr, *p nullptr; hints.ai_family AF_UNSPEC; // 不限定 IPv4/IPv6 hints.ai_socktype SOCK_STREAM; // TCP hints.ai_protocol IPPROTO_TCP; int rc getaddrinfo(host.c_str(), port.c_str(), hints, res); if (rc ! 0) { // gai_strerror(rc) 能给出可读错误 return -1; } int sockfd -1; for (p res; p ! nullptr; p p-ai_next) { sockfd socket(p-ai_family, p-ai_socktype, p-ai_protocol); if (sockfd 0) continue; if (connect(sockfd, p-ai_addr, p-ai_addrlen) 0) { break; // 连上了 } close(sockfd); sockfd -1; } freeaddrinfo(res);逻辑说明getaddrinfo返回的是一个链表因为一个域名可能对应多个地址。我们要挨个尝试第一个 connect 成功的就用它。参数上ai_family设AF_UNSPEC表示 IPv4 和 IPv6 都接受ai_socktype必须是SOCK_STREAM因为 HTTP 走 TCP。port传字符串80或443不要传整数这是getaddrinfo的接口约定。用完记得freeaddrinfo否则内存泄漏。3.2 发送请求一次 send 不一定发完拼好请求字符串后用send发出去。但要注意send返回值可能小于你请求发送的长度尤其在网络拥塞时。严谨的写法是循环发送直到全部发完std::string request GET path HTTP/1.1\r\n Host: host \r\n Connection: close\r\n \r\n; size_t total 0; while (total request.size()) { ssize_t n send(sockfd, request.data() total, request.size() - total, 0); if (n 0) { // 发送失败关闭连接返回 close(sockfd); return -1; } total static_castsize_t(n); }参数说明path是请求目标比如/或/api/data注意它必须以斜杠开头。host是域名用于 Host 头。send的第四个参数一般填 0除非你要用MSG_NOSIGNAL防止进程被 SIGPIPE 干掉——在 Linux 上写网络程序建议加上MSG_NOSIGNAL否则对端关闭后你再 send 会直接收到信号终止进程这是个经典翻车点。3.3 接收响应recv 循环与缓冲区拼接因为用了Connection: close接收逻辑可以简化成“读到 0 为止”std::string response; char buf[4096]; while (true) { ssize_t n recv(sockfd, buf, sizeof(buf), 0); if (n 0) { response.append(buf, static_castsize_t(n)); } else if (n 0) { break; // 对端正常关闭 } else { // n 0出错可检查 errno EINTR 重试 break; } } close(sockfd);逻辑说明recv返回正数表示读到了字节返回 0 表示对端关闭连接返回负数表示出错。EINTR表示被信号中断这种情况应该重试而不是当错误处理。缓冲区大小 4096 是个经验值太小会增加系统调用次数太大浪费栈空间。把每次读到的数据 append 到response里最后一次性解析。3.4 解析响应定位头部与正文的分界拿到完整响应后先找\r\n\r\nsize_t header_end response.find(\r\n\r\n); if (header_end std::string::npos) { // 响应不完整可能连接被提前断开 return -1; } std::string headers response.substr(0, header_end); std::string body response.substr(header_end 4); // 跳过空行 // 从状态行提取状态码 size_t sp1 headers.find( ); int status std::atoi(headers.c_str() sp1 1);参数说明header_end 4是因为\r\n\r\n占 4 个字节。状态码在状态行第一个空格之后用atoi直接转成整数。拿到状态码后就能判断请求是否成功200 是正常301/302 是重定向404 是找不到500 是服务端错误。注意这里只处理了最基础的情况如果响应头里声明了Content-Length理论上可以用它校验正文长度但既然用了 close 模式读到连接关闭就已经是完整正文了。4. 编译与跨平台Linux 直接过Windows 要换一套 API4.1 Linux 下的编译命令在 Linux 或 macOS 上这份代码只依赖系统自带库编译非常干净g -stdc11 -O2 -Wall -o httpget httpget.cpp ./httpget example.com 80 /参数说明-stdc11保证能用上现代语法-O2开优化-Wall打开警告方便发现隐患。运行时的三个参数分别是主机、端口、路径。macOS 上基本一致不需要额外链接库。如果你的代码里用了std::thread之类才需要加-pthread这份实现用不到。4.2 Windows 下的差异Winsock 初始化与类型替换Windows 用的是 Winsock2函数名和头文件都不一样。主要差异有这么几处头文件从sys/socket.h换成winsock2.h和ws2tcpip.h使用前必须调用WSAStartup初始化close换成closesocketsend/recv的返回类型是int而不是ssize_t。初始化代码大致是#ifdef _WIN32 WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { return -1; } // ... 程序结束时调用 WSACleanup(); #endif用条件编译把平台差异包起来同一份源码就能两边跑。常见做法是定义一组宏比如#define CLOSE_SOCKET closesocket让主逻辑保持统一。链接时 Windows 上要加-lws2_32MinGW 和 MSVC 的写法略有不同MSVC 是在项目属性里加依赖项。4.3 用 HTTPS 怎么办这份实现的边界必须说清楚这份实现只处理明文 HTTP也就是 80 端口。如果你请求的是https://开头的地址走的是 443 端口中间多了 TLS 握手和加密层socket 里收发的是密文直接发 HTTP 明文请求服务器根本不认。要在 C 里做 HTTPS绕不开 TLS 库常见选择是 OpenSSL 或 mbedTLS或者干脆用 libcurl 让它帮你处理。这份资源的价值在于把 HTTP 这一层讲透等你理解了明文流程再去看 TLS 就只是多了一层加解密不会觉得是黑匣子。5. 避坑与排查那些让请求静默失败的细节5.1 现象程序卡住不返回也不报错原因多半是漏了请求末尾的空行或者 Host 头没写。服务器在等完整的请求头客户端在等响应双方互相等待直到 TCP 超时。另一种可能是 DNS 解析慢getaddrinfo卡在解析阶段。解决抓包确认请求是否完整发出用tcpdump或 Wireshark 看实际字节。检查请求字符串结尾是不是\r\n\r\n。给 socket 设置超时用setsockopt配SO_RCVTIMEO和SO_SNDTIMEO避免无限等待。5.2 现象收到 301/302 但正文是空的原因请求的路径被重定向了比如访问example.com根路径时服务器让你跳到www子域或 HTTPS 版本。这份实现不自动跟随重定向所以拿到的是重定向响应本身正文通常很短。解决从响应头的Location字段读出新地址手动再发一次请求。如果要自动跟随得写个循环并限制最大跳转次数防止无限重定向。注意跨协议跳转HTTP 到 HTTPS时你的明文实现处理不了得换方案。5.3 现象中文或二进制内容乱码、被截断原因一是把响应体当字符串处理时没按字节长度截取遇到\0就断了二是没看Content-Type里的字符集直接按 UTF-8 打印 GBK 内容。三是前面提到的用整个缓冲区找分隔符正文里恰好有\r\n\r\n就误判了。解决正文一律用std::string按长度存储不要用 C 字符串函数。解析分隔符时只在头部区域查找找到第一个就停。输出前先看Content-Type需要转码就转码不需要就按二进制写文件。5.4 现象Linux 上进程莫名收到 SIGPIPE 退出原因对端已经关闭连接你还在send内核会发 SIGPIPE 信号默认行为是终止进程。这个错误很隐蔽因为程序看起来是“无缘无故”死的。解决发送时用send(sockfd, buf, len, MSG_NOSIGNAL)或者在程序开头signal(SIGPIPE, SIG_IGN)忽略该信号。前者更精细只影响这一次调用后者一刀切适合小工具。我一般两个都上双保险。5.5 现象大文件下载到一半内存爆掉原因这份实现把所有响应拼在一个std::string里下载几百 MB 的文件时内存直接顶满。解决改成边收边写文件recv到一块就fwrite一块不要全攒在内存里。如果确实需要完整内容再处理至少加个大小上限超过就中止。这是从“能跑”到“能用”必须迈过的一步。6. 进阶技巧加超时、写文件、封装成可复用函数把上面这些拼起来一个更实用的版本应该具备三个能力连接和读写都有超时、响应直接落盘、核心逻辑封装成函数方便调用。超时用setsockopt设置struct timeval tv; tv.tv_sec 5; // 5 秒超时 tv.tv_usec 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv));参数说明SO_RCVTIMEO控制recv的阻塞上限超时后返回 -1 且errno为EAGAIN或EWOULDBLOCK。5 秒是个折中值内网可以更短跨地域访问可以放宽到 10 秒。注意这个超时是每次 recv 调用的上限不是整个请求的总时长如果数据持续到达总时间可以超过 5 秒。落盘的话把接收循环里的 append 换成写文件FILE* fp fopen(output.bin, wb); // ... recv 循环内 if (n 0) { fwrite(buf, 1, static_castsize_t(n), fp); } // ... 循环结束后 fclose(fp);这样内存占用就是常数级的下载多大文件都不怕。最后把整个流程包成一个函数签名可以设计成int http_get(const std::string host, const std::string port, const std::string path, const std::string outfile)返回状态码出错返回 -1。调用方一行就能发请求内部细节全藏起来。验证方法很简单拿几个不同类型的地址测一遍。请求一个静态 HTML 页面看正文是否完整请求一个会 302 的地址确认能拿到 Location请求一个返回 JSON 的接口检查中文是否正常再下一个几 MB 的文件对比 md5 确认没损坏。这几步走完基本就能确认实现是可靠的。说个我自己的习惯。早年写这类网络代码我总是不设超时觉得“网络不会那么差”结果有次在弱网环境调试程序卡了十几分钟没反应排查半天才发现是 recv 一直阻塞。从那以后我每次写 socket 代码第一件事就是把收发超时和 SIGPIPE 处理加上哪怕是个一次性小工具也不例外。这个习惯帮我省了太多无谓的等待。希望这份实现和这些踩坑记录能帮到你拿去改改就能用在自己的项目里。本文还有配套的精品资源点击获取