简介:一份演示如何用C++实现HTTP GET请求的简洁代码包,主要面向初步接触网络编程、希望掌握基础请求流程的开发者。压缩包内仅3个文件,包括两个头文件与一个实现文件:头文件分别定义HttpGet请求类与Timer计时器类,声明了URL设置、请求执行、响应获取等接口以及高精度计时接口;实现文件则具体完成socket/第三方库调用等网络交互逻辑,并对请求耗时进行统计。整体包大小只有2KB,结构清晰,适合快速理解网络请求的代码组织方式。目前已有268人浏览学习。通过这份资源,读者可以直观掌握GET请求的发送与响应读取方式,了解如何利用Timer量化请求性能,也能为将来处理HTTPS、Cookie、代理等复杂场景打下基础。代码量少且层次分明,无论是自学还是作为网络编程课程参考,都具备不错的启发性。
1. 一个httpget要回答的三个问题:从connect到第一个字节之间发生了什么
抓网页这种事,后端工程师天天做,但真要你用C++手写一个httpget,很多人会卡在“从connect到拿到body”这段路。最常见的感受是:代码写出来了,数据也收到了,但遇到长连接、跳转、超时就翻车。这个标题讲的就是这件事——用一个最小可用的C++实现,把HTTP GET请求拆成socket建立、请求头发送、响应接收、body解析四个环节。它能解决什么?能让你在没有curl和libcurl的嵌入式环境里,只靠系统socket接口拿到网页内容;也能让你在排查线上接口问题时,不再把“HTTP响应长什么样”当黑匣子。适合谁看:刚接触socket的C++新手,以及那些在项目里被要求“不要依赖额外库”的工程师。简单,不等于没有边界,边界恰恰从第一个字节开始。
2. 先跑通再讲道理:用socket在本地搭一个能收到HTML的GET请求
写httpget之前,先明确一个边界:这里说的是明文HTTP,不是HTTPS。HTTPS需要在socket之上再加一层TLS握手和加密,那是另一个话题。如果你要请求的地址是https://,下面这段代码跑不通。原因不复杂——你发出去的请求头是明文,服务端在TLS层就会把你拒绝掉。所以本章所有示例都针对http://,做内网探活、抓本地服务页面、调试网关接口已经够用。
2.1 最小可运行骨架:带注释的socket请求主体
先看第一个能直接编译运行的版本。这段代码用阻塞socket,逻辑非常直白:创建socket、解析域名、connect、发请求、循环收数据。
#include <cstdio> #include <cstring> #include <string> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <netdb.h> static bool http_get(const std::string &host, const std::string &path, std::string &response) { // 1. 创建TCP socket。AF_INET表示IPv4,SOCK_STREAM表示流式TCP int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); return false; } // 2. 解析域名。gethostbyname是旧接口,生产环境建议换getaddrinfo struct hostent *he = gethostbyname(host.c_str()); if (he == nullptr) { fprintf(stderr, "gethostbyname failed: %s\n", host.c_str()); close(fd); return false; } // 3. 填充服务端地址结构。端口80对应HTTP默认端口 struct sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(80); memcpy(&addr.sin_addr, he->h_addr, he->h_length); // 4. connect建立TCP三次握手 if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { perror("connect"); close(fd); return false; } // 5. 构造GET请求头并发送 char req[1024]; snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "Connection: close\r\n" "\r\n", path.c_str(), host.c_str()); ssize_t sent = send(fd, req, strlen(req), 0); if (sent != (ssize_t)strlen(req)) { perror("send"); close(fd); return false; } // 6. 循环接收,直到服务端关闭连接 char buf[4096]; ssize_t n = 0; while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) { response.append(buf, n); } close(fd); return true; }这段代码里有几个参数决定成败。第一个是Connection: close,它的意思是告诉服务端:响应发完就断开。这样客户端 recv 循环读到 EOF(recv 返回 0)就可以结束,省去按 Content-Length 判断收尾的逻辑。第二个是Host:头,HTTP/1.1 强制要求,没有它很多 Nginx 会直接回 400。第三个是缓冲区大小 4096,对于绝大多数接口响应够用;单次可能收不完,所以必须循环 recv。
2.2 请求头拼写细节:为什么Host和User-Agent不能省
很多第一次写的读者会好奇:为什么一个GET还要带这么多头?你可以在终端里用nc example.com 80手动输入一行GET / HTTP/1.1,不加Host,然后按回车,大概率收不到正常响应,因为HTTP/1.1规定每个请求必须带Host头。Host头的作用是让一台服务器上的多个虚拟主机能区分请求归属。
User-Agent 常被忽略,但它决定了你是不是被服务器当成“非浏览器爬虫”直接拒掉。不少反爬策略会检查UA,空UA的请求会在网关层被拦,返回403、418甚至直接断连。常见做法是写一个看起来像浏览器的UA值,比如:
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36\r\n"不用照抄这个版本,关键是不要空着。另外请求行末尾必须是\r\n,而不是\n;HTTP协议规定换行符是CRLF。用snprintf拼接时,字符串里写的是\r\n,编译器会转成真正的回车换行。如果你不小心只写了\n,严格实现的服务器会直接丢掉这个请求。
请求头的拼接方式也有讲究。上面用snprintf拼一个定长数组,对固定路径是够的。如果路径里带动态参数,建议改成std::string再用append拼,避免数组越界。很多C++项目在vscode里配置好c/c++环境后直接编译,snprintf用起来比sprintf安全,这算是手写网络代码的底线。
2.3 recv循环读数据:缓冲区大小、半包与粘包
初学socket的人最容易犯的错是“一次recv就想拿到全部响应”。TCP是字节流,没有消息边界。内核把网络包拼成一段一段交给你,recv一次返回多少完全不确定。你请求一个网页,响应可能有几百KB,一个4KB的buffer要分几十次收完;如果网络慢,可能收一半就阻塞住。所以代码里必须写while循环。
recv返回值有三种情况需要区分:
- 大于0:收到数据,追加到response里
- 等于0:服务端关闭连接,说明响应发完了,循环结束
- 小于0:出错。如果是
EINTR(被信号中断)要重试,如果是EAGAIN/EWOULDBLOCK说明超时或非阻塞模式下缓冲区暂时没数据,得根据场景决定是否退出
上面代码里没处理EINTR,真实环境建议加一行判断:
if (n < 0 && errno == EINTR) continue;缓冲区大小选4KB还是64KB,影响的是内存占用和系统调用次数。64KB的栈上数组在多数平台没问题,但如果你在嵌入式环境里栈只有1MB,放一个64KB的局部数组有风险。保守做法是16KB,性能和安全性比较均衡。另一个点:粘包不发生在HTTP里,因为HTTP响应会自己用Content-Length或chunked标明长度。你只需要负责把字节流完整读出来,解析问题留给下一章。
3. 把响应拆开:状态行、Header和Body的分界点与三个容易错的地方
收完所有字节之后,真正干活的部分才开始。很多人看到一坨字符串直接rfind("<html>")截取,这种做法对零零散散的小接口可能正好能用,但遇到大响应、chunked编码、或响应体里恰好包含HTML标签时就会出毛病。正确的做法是按HTTP协议结构解析。
3.1 状态行解析:200不一定代表成功,301要读Location
响应第一行叫状态行,格式如下:
HTTP/1.1 200 OK HTTP/1.1 301 Moved Permanently HTTP/1.1 404 Not Found用sscanf解析这行,把状态码取出来:
// response是上一章收完的完整响应字符串 std::string status_line = response.substr(0, response.find("\r\n")); int http_code = 0; char version[16] = {0}; char reason[64] = {0}; sscanf(status_line.c_str(), "HTTP/%15s %d %63[^\r\n]", version, &http_code, reason); printf("version=%s code=%d reason=%s\n", version, http_code, reason);%63[^\r\n]的作用是读reason文本但不吃回车,避免把\r带进来。状态码这块要注意:200才是成功,201、202也常见但GET很少返回;301、302表示重定向,此时响应体一般不是你要的内容,要读Location响应头;403、404、500、502都需要单独处理,不能笼统当成功。很多初写httpget的人只判断“HTTP/”前缀存在就当成功,结果拿着404页面解析了半天,这是一条非常典型的血泪经验。
3.2 Content-Length与Transfer-Encoding:两种最常见的长度判定
响应头从状态行之后开始,到第一个空行结束。空行就是\r\n\r\n。我们要在头里找两个字段。
Content-Length:响应体字节数。服务端说多少就是多少。Transfer-Encoding: chunked:响应体被分成多个块,每块前面用十六进制标长度,块与块之间用\r\n分隔。
解析响应头最稳妥的方式是从\r\n\r\n处切开,再按\r\n逐行遍历。注意HTTP头里的字段名不区分大小写,所以比较时用strncasecmp,不要用==做精确比较。
size_t header_end = response.find("\r\n\r\n"); if (header_end == std::string::npos) { fprintf(stderr, "invalid http response: no header separator\n"); return; } std::string header_part = response.substr(0, header_end); std::string body_part = response.substr(header_end + 4); size_t lp = 0; int content_length = -1; bool chunked = false; while (lp < header_part.size()) { size_t eol = header_part.find("\r\n", lp); if (eol == std::string::npos) eol = header_part.size(); std::string line = header_part.substr(lp, eol - lp); if (strncasecmp(line.c_str(), "Content-Length:", 15) == 0) { content_length = atoi(line.c_str() + 15); } else if (strncasecmp(line.c_str(), "Transfer-Encoding:", 18) == 0) { if (line.find("chunked") != std::string::npos) { chunked = true; } } lp = eol + 2; }这里要注意三个边界坑。第一个:Transfer-Encoding优先级高于Content-Length。如果两个都出现,按Transfer-Encoding处理;目前主流服务器不会同时发,但代理服务器偶尔会。第二个:Content-Length可能是0,表示空body,此时body_part为空是正常的,别当异常。第三个:HTTP头里可能插入多余空格,比如Content-Length: 123正常,但Content-Length:123也能被解析。atoi会自动跳过前导空格,所以上面的代码对这两种都能处理。
3.3 三种body形态的收尾逻辑:固定长度、chunked与keep-alive
确定body有多长之后,收尾逻辑就能写明白。理想情况下,你在第2章已经用Connection: close简化了服务端行为,服务端发完body就断开连接,上面的body_part大概率已经是完整body。但是如果服务端坚持Connection: keep-alive,或者你的请求头里没写Connection: close,那么连接不会断,你收完一个响应后,下一个响应可能还在路上。此时就必须按Content-Length准确截断。
if (!chunked && content_length >= 0) { if ((int)body_part.size() >= content_length) { body_part = body_part.substr(0, content_length); } else { // 说明半包了。需要继续读socket直到凑够Content-Length,或者直接报错重试 fprintf(stderr, "body incomplete: got %zu, want %d\n", body_part.size(), content_length); } }如果是chunked,则body里不是直接的内容,而是一串结构:
1f\r\n{"message":"hello world"}\r\n 0\r\n \r\n第一个1f是十六进制数,表示后面块的长度(31字节)。读完整个块后,紧跟一个\r\n。然后继续读下一块,直到遇到0\r\n。解析chunked时我一般这样处理:
// 简单chunked解析,假设chunk数据里已经存到body_part std::string dechunked; size_t i = 0; while (i < body_part.size()) { size_t line_end = body_part.find("\r\n", i); if (line_end == std::string::npos) break; std::string size_line = body_part.substr(i, line_end - i); // 分块长度可能带扩展属性,比如 "1f;ext=1",分号后面忽略 size_t semicolon = size_line.find(';'); if (semicolon != std::string::npos) size_line = size_line.substr(0, semicolon); long chunk_size = strtol(size_line.c_str(), nullptr, 16); if (chunk_size == 0) break; // 0表示结束块 i = line_end + 2; dechunked.append(body_part, i, chunk_size); i += chunk_size + 2; // 跳过块数据后面的CRLF } body_part = dechunked;strtol的第三个参数传16,表示按十六进制解析;不传的话,1f会被解析成0。另外chunk-size理论上可以带分号扩展,但实际服务器很少用,做兼容时可以忽略分号后的内容,不要因此翻车。
到这里,一个httpget已经能正确拿到body。接下来要考虑的是让它更接近真实工具。
4. 让httpget更像工具:URL编码、超时和重定向怎么处理
真实的HTTP GET请求很少只访问/根路径。大部分时候你要带query参数、要设超时防止卡死、遇到301还要跟着跳。这三个问题如果不处理,你的httpget只能在本地测试环境里跑,拿不到线上数据。
4.1 query string编码:中文和特殊字符为什么必须转义
URL里?后面是query string,格式是key1=value1&key2=value2。但值里如果含中文、空格、&、=等字符,必须转义。否则服务端可能把值的&当作下一个参数分隔符,或者因为收到非ASCII字节直接返回400。
手工拼URL时最稳妥的做法是写一个简单的URL编码函数:
static std::string url_encode(const std::string &s) { std::string out; for (unsigned char c : s) { // 字母、数字和这几个保留字符不需要编码 if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { out += (char)c; } else { // 其他字符全部转成 %XX char hex[4]; snprintf(hex, sizeof(hex), "%%%02X", c); out += hex; } } return out; }这段代码把一次请求里所有要传的中文、空格、特殊符号做成百分号编码。参数说明:unsigned char很关键——如果直接用char,printf时的%02X会把字符当作有符号数处理,汉字的高位字符会扩展成FFFFFFxx,导致编码错误。这是不少人在C++项目里做HTTP工具时踩过的一个典型的类型坑。
另一个容易忽略的点是空格。空格在query里可以编码成%20,也可以用+代替。url_encode里空格属于非保留字符,统一编成%20,大多数服务端都能正确解码。如果你对接的是老式PHP后端,习惯把空格解成+,不过这点在新项目里影响不大。
拼URL时还需要注意:?和&这两个字符不要用这个函数编码。它们是query的分隔符,属于URL结构。正确的处理是只对每个key和value的值做编码,再拼回key=value&格式:
std::string query; query += "q=" + url_encode(keyword); query += "&page=" + url_encode(page_str); std::string full_path = "/search?" + query;4.2 超时控制:不设SO_RCVTIMEO的程序会卡在recv
默认情况下,socket的recv是阻塞的。如果服务端接受连接后一直不发数据,你的httpget会一直卡在recv里,像死了一样。特别是请求一个不存在的路径,有些服务端会先响应200,然后慢慢吐数据,吐到一半就停,此时recv永远等在“还有后续数据”的状态里。解决办法是给socket设置收发超时。
#include <sys/time.h> struct timeval tv{}; tv.tv_sec = 5; // 5秒超时 tv.tv_usec = 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));设置之后,如果recv在5秒内没有收到数据,会返回-1,errno被设为EAGAIN或EWOULDBLOCK。你需要在收数据的循环里把这种情况当成超时错误处理,而不是继续无限等:
if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { fprintf(stderr, "recv timeout\n"); break; } else if (errno == EINTR) { continue; } perror("recv"); break; }connect也可能卡住。如果目标IP是内网一个不存在的地址,connect可能要等内核超时(通常是几十秒到几分钟)。要给connect加超时,常见做法是把socket先设为非阻塞,再调用connect,然后用select等待可写,最后用getsockopt检查连接结果:
#include <fcntl.h> #include <errno.h> fcntl(fd, F_SETFL, O_NONBLOCK); int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); if (ret != 0) { if (errno == EINPROGRESS) { fd_set wset; FD_ZERO(&wset); FD_SET(fd, &wset); struct timeval tmo{}; tmo.tv_sec = 3; if (select(fd + 1, nullptr, &wset, nullptr, &tmo) > 0) { int so_error = 0; socklen_t len = sizeof(so_error); getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &len); if (so_error != 0) { close(fd); return false; } } else { close(fd); return false; // 连接超时 } } }这段代码解释一下:非阻塞connect通常返回-1并把errno设为EINPROGRESS,表示连接还在进行。select等待这个socket可写,可写时结果要么成功要么SO_ERROR里有错误码。很多人写完这步就忘了调用getsockopt,结果连不连得上全凭“玄学”,这一点值得记进笔记。
4.3 处理301/302重定向:最多跳5次,避免死循环
很多网站会把http://跳转到https://,或者把无www域名跳转到带www的域名。如果你固执地拿最初的URL去发请求,拿到的body可能只有一行“Moved Permanently”,而不是目标页面。正确做法是:识别到301/302后,取响应头里的Location字段,重新发起请求。
// 伪代码:在http_get里递归调用 static bool http_get_with_redirect(const std::string &url, int max_jumps) { if (max_jumps <= 0) { fprintf(stderr, "redirect too many times\n"); return false; } // 1. 发送并解析响应 // 2. 如果状态码是301/302,取header里的Location // 3. 拼成新的完整URL,递归调用 return http_get_with_redirect(new_url, max_jumps - 1); }Location的取值有两类,需要分开处理。第一类是完整URL,如https://example.com/page,直接替换。第二类是相对路径,如/redirect?from=old,需要和原URL的host拼起来。注意:如果Location是https://而你只实现了明文HTTP,这里就要报错,不要硬发。这属于你明确做了的边界。
我在实际项目里会额外记录重定向的次数和最终URL,这对排查“为什么请求到的内容和浏览器不一样”非常有用。curl默认最多跟随30次重定向,我自己的httpget一般设5次封顶,够用且能避免循环跳转的死循环。
5. 避坑记录:五个让httpget翻车的典型案例与处理办法
这一章记录的是真正跑起来才会遇到的坑,不是理论推导。每一条都按“现象→原因→解决”的顺序写,你可以直接对应自己的代码逐一排查。
5.1 现象:返回内容只剩一半,body不完整
发请求后收到的内容明显是截断的,比如一个HTML页面只剩下前半部分,完全没有报错。原因通常是解析时用了response.find("</html>")或按recv一次的数据当完整body,忽略了TCP的半包。recv一次最多返回当前缓冲区里已有的内容,如果服务端还没把后半段发完,你的body就是不完整的。解决方法是第2章那种循环recv,或者更稳妥地按第3章的Content-Length截断。如果recv循环已经写了但依然只有一半,检查是否把Connection: close写成了Connection: keep-alive——keep-alive时服务端不会关闭连接,recv循环只能等超时,数据未必收全。
5.2 现象:中文URL返回404,英文URL正常
Query里带中文,直接拼进URL发出去,服务端返回404或“Bad Request”。原因:HTTP协议里URL只允许ASCII字符,中文字节会被当作非法字符,不同服务器处理方式不同,有的直接拒绝。解决:用第4章的url_encode对参数编码。只要编码函数里用了unsigned char,编码出来的%E4%B8%AD%E6%96%87就是合法的。一个自查技巧:在抓包里看发的请求行,如果能看到中文字符,那一定有问题。
5.3 现象:程序卡在recv里不退出
请求一个不存在或不正常的接口时,程序停在recv不动,Ctrl+C才能结束。原因:socket没有超时设置,或设置超时但recv出错时没有break退出。解决:在connect之后立刻设置SO_RCVTIMEO,并在recv返回-1、errno为EAGAIN/EWOULDBLOCK时跳出循环。连接建立阶段也用第4章的select方式控制connect超时。注意超时是“两次读取之间的间隔”,不是总时长;如果服务端每3秒吐一块数据,5秒超时可能永远不触发,这是常见误读。
5.4 现象:请求自己的测试服务收到403 Forbidden
用httpget请求一个Nginx或网关接口,返回403,而浏览器访问同一地址正常。原因:请求头里User-Agent留空或者带有“python/httpget”等特征,被网关的反爬规则拦了。很多C++项目里调试时根本没设置UA,等于告诉服务器“我不是浏览器”。解决:在请求头里加上浏览器风格的User-Agent。如果仍被拦,再加Accept: text/html,application/xhtml+xml,*/*;q=0.8和Accept-Language: zh-CN,zh;q=0.9,一般问题就解决了。
5.5 现象:chunked响应解析后body乱码
响应是chunked编码,直接按纯文本处理,得到的body里有大量十六进制数字和多余的CRLF。原因:没识别Transfer-Encoding,或者把chunk-size行也当成body内容。解决:按第3章的chunked流程解析,先读一行十六进制size,strtol(..., 16)转成长度,再读等长数据,再消费掉块后的CRLF,直到读到0\r\n结束。如果body最后仍多出几字节,通常是忘了跳过结束块后面的CRLF,或0\r\n后面还有trailer头(很少见,一般不需要处理)。
6. 验证你的httpget:抓包对照和什么时候改用libcurl
6.1 用本地测试服务和抓包验证你没少写Header
写完了httpget,怎么确认它是对的?最直接的办法是在本地起一个测试服务,让httpget去请求,再用tcpdump或Wireshark抓包对照。比如在本机用Python起一个临时HTTP服务器:
python3 -m http.server 8080然后运行你的httpget访问http://127.0.0.1:8080/index.html。重点看两件事:第一,服务端返回的是200还是400;第二,抓包看实际发出的请求行和Header。抓包命令:
tcpdump -i lo port 8080 -A-A选项会直接打印ASCII内容,你能直观看到自己发的请求头是否含有正确的\r\n、Host头、UA。如果抓包里只看到一行GET /index.html HTTP/1.1后面没有Host,那说明拼接请求头时丢了字段。这种对照方法比用浏览器开发工具更直接,因为你看到的是自己代码真正发出的字节,不是库替你补全后的结果。
还要验证超时行为。让本地server接受连接但不响应,比如用nc -l 8080然后什么都不输入,httpget应该在设定的超时时间后主动退出并报错,而不是永远挂着。这一条实操起来成本很低,但能帮你发现超时逻辑里的隐藏bug。
6.2 什么时候该换libcurl而不是继续手写
手写httpget最大的优势是零依赖,适合内网探活、嵌入式环境、或者只想搞懂协议原理的场景。可一旦需求变多,手写版本的成本会迅速上升。需要处理HTTPS、携带Cookie、自定义Headers、HTTP/2、代理、上传文件时,手写socket就像重新发明轮子。
我用一张表来对照选型:
| 场景 | 手写socket | libcurl |
|---|---|---|
| 内网HTTP探活 | 适合,零依赖 | 适合,略重 |
| 需要HTTPS | 要接TLS库,工作量大 | 内置支持,直接可用 |
| 需要Cookie会话 | 自己维护Cookie头 | 提供Cookie引擎 |
| HTTP/2 | 很难实现 | 支持 |
| 代理访问 | 要自己实现CONNECT或代理头 | 内置多种代理协议 |
| 二进制下载大文件 | 需自己处理落盘逻辑 | 有回调接口 |
如果你只是想把一次GET做到能跑,手写是练习和理解的好路径;如果这是生产系统里的功能,直接上libcurl更稳。我自己的习惯是:教学笔记和探活小工具用手写版本,凡是涉及用户会话、HTTPS、对外接口的代码,一开始就引libcurl。这样能省掉后面一连串的“优化手写版”时间。
最后说说验证边界的一个小习惯:我会在httpget里加一个调试开关,打印出完整响应头和状态码。线上出问题时,先看状态行,再查Content-Length是否和实际长度一致,再决定是网络问题还是解析问题。这个顺序帮我解决过不少看起来像是“服务器发错数据”,实际是自己漏写了Accept头的案例。写网络代码多留一条打印路径,排查问题时就像吃了后悔药一样踏实。希望帮到你。
本文还有配套的精品资源,点击获取