简介:面向 Windows 网络编程开发者,这套源码提供 libcurl 与 WinHTTP 两套实现,覆盖 HTTP/HTTPS 协议下的 GET/POST 请求封装。资源以 CHttpClient 与 WinHttpClient 两个类为核心,可直接嵌入 C++ 项目,适合接口调试、数据抓取及服务端通信等场景,适合有一定基础并想对比两种网络库用法的读者。压缩包共 54 个文件,包含 17 个头文件、12 个 lib 库、4 个 cpp 源文件,另有 Visual Studio 解决方案、工程配置、bat 清理脚本及编译产出,整体仅 6.24MB。配套 curlDemo 示例工程展示了源码、依赖库与 Debug 生成结果,能帮助理解类封装方式并快速整理构建环境。该资源已有 478 人学习,对需要落地 HTTP/HTTPS 请求功能的开发者有直接参考价值。 做网络请求这件事,看起来简单,真正上手总会遇到一堆莫名其妙的问题。我这次整理的是一个前后折腾过几轮的HTTP/HTTPS客户端组件,核心就是GET、POST两类请求,分别用curl和WinHTTP各实现了一套。做这个东西的起因是手头有个Windows服务需要调第三方API,又要兼容旧系统、又不想引入太重的依赖,于是干脆把两种方案都写了一遍,顺手把踩过的坑也记了下来。这篇就围绕这两个版本的实现展开,讲清楚概念、代码、HTTP和HTTPS的差异、GET和POST怎么选,以及调试时最容易翻车的几个点,适合正在写网络请求、处理API对接或者排查连接异常的开发者参考。
1. 先把概念理清:HTTP/HTTPS 与 GET/POST 到底在做什么
写代码之前,我的习惯是先花十分钟把协议字段和报文结构理清楚。很多看起来玄学的报错,比如“返回403”“连接被重置”“TLS握手失败”,根子上都是概念没吃透。
1.1 HTTP 是怎么工作的
HTTP本质上是客户端和服务端之间的一种文本约定。客户端发一个请求头,里面带上请求方法、路径、Host、Content-Type这些字段,服务端解析后返回状态行、响应头和响应体。整个过程是无状态的,需要保持会话就靠Cookie或者Token之类的机制自己实现。
请求方法里最常见的就是GET和POST。GET的语义是“去取资源”,参数拼在URL查询串上,像?key=value&page=1;POST的语义是“提交数据让服务端处理”,数据放在请求体里,可以是表单、JSON、文件等。从协议设计来讲,GET应该是对服务端无副作用的只读操作,POST则允许修改状态。我在实际对接时遇到过一些团队把删除操作也设计成GET,虽然能用,但一旦经过网关或者日志系统,很容易被爬虫或预取误触发,所以设计API时尽量遵守语义。
还有个坑是“POST和GET”在浏览器里刷新时行为不同,但我们在写原生客户端时通常不关心这个,关心的主要是:参数位置、编码方式、服务端框架如何解析。很多“Qt post请求 无法获取”这类问题,其实就是服务端框架默认从查询串取参数,而客户端把参数放进了body,两边没对齐。
1.2 为什么要上 HTTPS
HTTPS就是在HTTP外面套了一层TLS/SSL加密。它能保证三件事:内容不被中间人看到、内容不被篡改、服务器身份可验证。这三点对登录接口、支付回调、Token交换这类场景是刚需。
真正开始写代码后你会发现,HTTPS带来的复杂度主要集中在证书验证和TLS版本上。curl在Windows上默认走Schannel,WinHTTP也走系统证书库,这本来很方便,但公司的内网环境经常有自签名证书、代理劫持证书或者旧系统只支持TLS 1.0,于是各种schannel错误就冒出来了。处理原则是:生产环境必须校验证书,测试环境可以临时放宽,但绝不能把“跳过证书校验”写死到线上代码里。
1.3 GET 和 POST 的区别与选择
除了协议规范,实际编码中GET和POST的区别主要在三个地方:
| 对比项 | GET | POST |
|---|---|---|
| 参数位置 | URL查询串 | 请求体 |
| 长度限制 | 受URL长度限制,通常几KB到几十KB | 由服务端配置决定,可传大文件 |
| 幂等性 | 一般幂等 | 不保证幂等 |
| 安全程度 | 参数可被日志、历史记录看到 | 相对隐蔽,但仍需HTTPS保护 |
做选型时我的经验是:查询类、跳转类、只读接口用GET;创建、修改、删除、登录这类有副作用的操作用POST。另一个容易被忽略的是缓存:GET请求可以被浏览器和CDN缓存,POST默认不缓存,如果你希望接口结果被加速,GET是更好的选择。
2. 方案选型:为什么同时保留 curl 和 WinHTTP 两套实现
一开始我觉得只要一个方案就够了,真做起来才发现不同环境有不同约束。curl和WinHTTP各有不可替代的场景,两个版本都保留不是冗余,而是为了在不同环境里都能“有得选”。
2.1 curl 版:轻量、跨平台、生态好
curl的优势是社区生态成熟、命令行可用、支持HTTP/2、支持代理、支持各种TLS后端,而且几乎所有语言都有libcurl绑定。你在Windows命令行里直接敲curl.exe就能调接口,排查问题特别方便,很多报错信息也直接给到命令行。
我在项目里用libcurl主要看重两点:一是可以自定义header、控制超时、设置代理,二是能方便地接收响应头和响应体,做断点续传、多线程下载也比自己写socket省事得多。缺点也有:Windows下分发的curl版本如果依赖具体的TLS后端,换台电脑可能行为不完全一致;旧系统像32位Win7还需要找对对应的curl单文件版本,不然装上去就跑不起来。
2.2 WinHTTP 版:Windows 原生、部署简单
WinHTTP是Windows自带的HTTP客户端API,不需要任何第三方DLL,完全调用系统组件。对于要部署到多个Windows服务器上的工具来说,这优势非常明显:拷个exe过去就能跑,不用管运行库、证书库、环境变量。
WinHTTP还支持NTLM、Kerberos这些Windows域认证,对接企业内网系统时很省事。代价是API比较古老,代码啰嗦,出错排查比curl困难,文档少且示例参差不齐。我自己封装的时候花了不少时间在句柄释放和同步等待上。
2.3 我的取舍原则
综合下来,我的使用原则是:
- 需要跨平台、快速迭代、调试频繁时,用curl/libcurl。
- 目标环境是纯净Windows、希望零依赖、要对接域认证时,用WinHTTP。
- 两边都保留一个统一接口,上层业务只关心请求URL、方法、header、body、超时这几个参数,底层具体走哪个实现由编译开关或运行时配置决定。
这样设计还有一个好处:万一某个实现出现诡异问题(比如证书校验失败、版本兼容差异),可以立刻切到另一套对照测试,快速定位是业务代码还是底层网络栈的问题。
3. curl 版本实战:命令行与 libcurl 双模式
curl版本我分两层写:一层是命令行,适合调试和临时任务;另一层是libcurl,适合集成到程序里。
3.1 最常用的 curl 命令行 GET/POST
先看最简单的GET,我把响应头和响应体都打出来:
curl -X GET "https://api.example.com/user?id=123" -H "Accept: application/json" -i-X GET可以省略,但写上更明确;-H加自定义头;-i显示响应头。POST JSON则是:
curl -X POST "https://api.example.com/user" \ -H "Content-Type: application/json" \ -d '{"name":"test","age":18}' \ -i这里-d默认是application/x-www-form-urlencoded,如果服务端是Spring Boot、Flask这类框架,经常要求Content-Type必须明确为application/json,所以头不能漏。如果带了-d,curl会自动加Content-Type: application/x-www-form-urlencoded,有的服务端会因此解析不了JSON,我踩过不止一次。
还有一个常用参数是-k,跳过证书校验,只建议在调试自签名证书时用:
curl -k https://192.168.1.10:8443/api3.2 libcurl 集成:C/C++ 最小可运行代码
libcurl在C/C++里的典型流程是:curl_easy_init初始化会话,设置URL、请求方法、请求头、写回调,然后curl_easy_perform执行,最后清理。下面是一段能跑的POST示例:
#include <stdio.h> #include <string.h> #include <curl/curl.h> static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { fwrite(ptr, size, nmemb, (FILE *)userdata); return size * nmemb; } int main(void) { CURL *curl; CURLcode res; FILE *fp = fopen("response.txt", "wb"); curl_global_init(CURL_GLOBAL_DEFAULT); curl = curl_easy_init(); if (curl) { struct curl_slist *headers = NULL; const char *body = "{\"name\":\"test\",\"age\":18}"; headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Accept: application/json"); curl_easy_setopt(curl, CURLOPT_URL, "https://api.example.com/user"); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); // 仅测试环境 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); // 仅测试环境 res = curl_easy_perform(curl); if (res != CURLE_OK) { fprintf(stderr, "curl_easy_perform() failed: %s\n", curl_easy_strerror(res)); } curl_slist_free_all(headers); curl_easy_cleanup(curl); } fclose(fp); curl_global_cleanup(); return 0; }编译时注意链接libcurl库,Linux一般-lcurl,Windows按安装的库版本选择lib文件。拿到响应后,解析JSON、处理编码就是另一层的事了。我的经验是:所有回调里集中处理内存或文件写入,不要在回调里再发起新请求,容易导致死锁和栈溢出。
3.3 HTTPS 证书与 TLS 细节问题
代码跑起来后,最常见的问题是TLS握手失败。比如这个经典报错:
curl: (35) schannel: next InitializeSecurityContext failed: SEC_E_INVALID_TOKEN我遇到这个一般有两种原因:一是服务器要求更高版本的TLS,而Windows的Schannel默认策略没跟上;二是连接被代理或者网络设备重置了,TCP没有正常完成握手。排查步骤:
- 先看curl版本和TLS后端:
curl -V,确认是Schannel还是OpenSSL。 - 加
-v看详细日志,定位是DNS解析、TCP连接还是TLS握手阶段失败。 - 用
--tlsv1.2或--tls-max 1.2强制TLS版本,看能不能过。 - 检查系统时间是否正确,时间不对会导致证书验证失败。
还有个常见错误是curl error (6): couldn't resolve host name,这个多半是DNS问题,不一定在代码里,可能是系统代理或hosts配置导致。用nslookup确认域名解析,再检查curl是否走了不期望的代理:curl --noproxy "*" https://example.com可以排除代理影响。
4. WinHTTP 版本实战:从零封装 GET 和 POST
WinHTTP的API风格和libcurl完全不同,它是Win32那套“初始化句柄-调用-清理”的模式。封装层不复杂,但细节很多,稍不注意就内存泄漏或句柄泄漏。
4.1 WinHTTP API 基本调用流程
核心步骤是:
WinHttpOpen创建会话句柄,指定User-Agent和代理配置。WinHttpConnect连接到目标服务器,传入主机名和端口。WinHttpOpenRequest创建请求句柄,指定方法(GET/POST)、路径、协议版本、安全标志。WinHttpSendRequest发送请求,POST时在这里附带请求体数据。WinHttpReceiveResponse接收响应头。- 循环调用
WinHttpQueryDataAvailable和WinHttpReadData读取响应体。 - 清理各层句柄。
整个过程是同步阻塞的,如果对端响应慢,UI线程会被卡住。实际项目里要么放到工作线程,要么用异步模式,但我为了可读性先讲同步版。
4.2 完整代码:GET 请求实现
用WinHTTP发GET请求,最精简的实现如下:
#include <windows.h> #include <winhttp.h> #include <stdio.h> #pragma comment(lib, "winhttp.lib") void HttpGet(const wchar_t* host, int port, const wchar_t* path) { HINTERNET hSession = WinHttpOpen(L"HttpClient/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); HINTERNET hConnect = NULL; HINTERNET hRequest = NULL; if (hSession) { hConnect = WinHttpConnect(hSession, host, port, 0); } if (hConnect) { hRequest = WinHttpOpenRequest(hConnect, L"GET", path, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, port == 443 ? WINHTTP_FLAG_SECURE : 0); } if (hRequest) { BOOL ok = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0); if (ok && WinHttpReceiveResponse(hRequest, NULL)) { DWORD available = 0; do { WinHttpQueryDataAvailable(hRequest, &available); if (available > 0) { char buf[4096]; DWORD read = 0; WinHttpReadData(hRequest, buf, min(available, sizeof(buf)), &read); fwrite(buf, 1, read, stdout); } } while (available > 0); } else { DWORD err = GetLastError(); printf("send/receive failed: %u\n", err); } } if (hRequest) WinHttpCloseHandle(hRequest); if (hConnect) WinHttpCloseHandle(hConnect); if (hSession) WinHttpCloseHandle(hSession); }注意port == 443 ? WINHTTP_FLAG_SECURE : 0,这是决定走不走HTTPS的关键。如果不加这个flag,即使你连的443端口,也会先发明文HTTP,握手直接就挂了。
4.3 完整代码:POST JSON 数据
POST的关键是填好请求体长度和内容类型。WinHTTP在WinHttpSendRequest里会把请求体数据带出去,但必须告诉服务端数据的长度和类型。处理不当就会出现“服务端收到请求但取不到body”的情况。
void HttpPostJson(const wchar_t* host, int port, const wchar_t* path, const char* jsonBody) { HINTERNET hSession = WinHttpOpen(L"HttpClient/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); HINTERNET hConnect = WinHttpConnect(hSession, host, port, 0); HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", path, NULL, WINHTTP_NO_REFERER, L"application/json", port == 443 ? WINHTTP_FLAG_SECURE : 0); const wchar_t* headers = L"Content-Type: application/json\r\n"; size_t bodyLen = strlen(jsonBody); BOOL ok = WinHttpSendRequest(hRequest, headers, (DWORD)-1L, (LPVOID)jsonBody, (DWORD)bodyLen, (DWORD)bodyLen, 0); if (ok && WinHttpReceiveResponse(hRequest, NULL)) { // 读取响应,逻辑同上,略 } WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); }这里WinHttpOpenRequest的AcceptTypes参数在POST时我直接传L"application/json",它不是Content-Type,而是Accept,容易出现误解。真正声明请求体格式的是后面WinHttpSendRequest里的headers字符串,这一点和curl的-H "Content-Type: application/json"是同一个意思。很多对接问题都是因为这两处搞混,导致服务端返回415 Unsupported Media Type。
4.4 HTTPS 与代理处理
WinHTTP默认使用系统代理配置,这在有些内网里很省心,但在本地调试时会绕到不存在的代理上,导致请求卡住或失败。解决办法是WinHttpOpen时显式指定WINHTTP_ACCESS_TYPE_NO_PROXY,绕过代理:
HINTERNET hSession = WinHttpOpen(L"HttpClient/1.0", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);如果必须走代理,可以用WINHTTP_ACCESS_TYPE_NAMED_PROXY并传入代理地址。代理会让很多HTTPS问题变得更难排查,因为TLS握手的数据也被代理“代劳”了,证书校验可能失败,HTTP状态码也可能被代理改写。遇到不明原因的错误时,我习惯先禁掉代理复测,往往能一眼定位问题。
5. 两套实现的对比与踩坑记录
代码写完只是第一步,真正麻烦的是线上环境的各种差异。下面是我的对比和排错总结。
5.1 对比表格
| 维度 | curl / libcurl | WinHTTP |
|---|---|---|
| 跨平台 | 良好,Windows/Linux/macOS都能用 | 仅Windows |
| 依赖 | 需引入curl库或可执行文件 | 系统自带,零依赖 |
| TLS后端 | OpenSSL/Schannel/BoringSSL等 | Schannel |
| HTTP/2支持 | 取决于版本,默认可开启 | 新版WinHTTP支持,但旧系统不行 |
| 域认证 | 需配置 | 原生支持NTLM/Kerberos |
| 调试友好度 | 高,命令行和日志都很详尽 | 低,只有错误码 |
| 内存/句柄管理 | libcurl内部管理,用户管会话 | 需要手动管理多层HINTERNET句柄 |
| 适用场景 | 跨平台工具、脚本、快速开发 | Windows服务、内网工具、安装包 |
这套对比也回答了“为什么两个都要”:一个是灵活,一个是省心。我在实际项目中通常用编译宏或一个配置文件切换,编译时#ifdef USE_WINHTTP决定走哪个实现,接口不变。
5.2 常见错误排查速查表
我把这段时间遇到的高频错误整理成一张表,方便对照处理:
| 错误现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
curl: (6) couldn't resolve host name | DNS解析失败、系统代理干扰 | 检查域名可解析性;用--noproxy "*"绕过代理再测 |
curl: (35) schannel: ... SEC_E_INVALID_TOKEN | TLS版本不匹配、代理重置连接 | 查看curl -V确认TLS后端;强制--tlsv1.2;检查系统时间 |
curl: (35) TCP connection reset by peer | 服务端主动断开、防火墙拦截 | 抓包确认SYN/ACK;换网络环境对照测试 |
upstream returned http 403 forbidden | 鉴权失败、User-Agent被限制、IP被拉黑 | 带上合法Token;检查User-Agent;确认是否有WAF拦截 |
unexpected status 502 bad gateway | 网关或后端服务异常 | 重试;看上游服务健康状态;检查POST body格式 |
request method 'post' not supported | 服务端不支持POST到该路径 | 确认API文档;检查方法拼写和URL路径 |
WinHTTP返回ERROR_WINHTTP_SECURE_FAILURE | 证书验证失败、TLS版本过低 | 确认证书链;必要时临时设置WINHTTP_OPTION_SECURITY_FLAGS忽略错误(仅测试) |
| 程序能连HTTPS但收到明文内容乱码 | 忘了给WinHttpOpenRequest加WINHTTP_FLAG_SECURE | 检查端口443时是否带安全标志 |
排查这类网络问题时,我有个习惯:先复现,再二分排除。复现时用最简参数,比如单独用curl请求同一地址,能通说明代码问题,不能通则先查网络和证书。再加参数区分是代理、TLS还是body格式问题,一条条加回来,很快能定位。
5.3 排错实录:一次 WinHTTP 连接复用导致的诡异行为
最后分享一个印象比较深的例子。做连接池测试时,我把WinHTTP封装成了复用HINTERNET hConnect的方式,请求同一个服务器多个路径。结果发现第二次请求总是偶发超时,后端日志显示只收到了第一次请求。查了半天发现是WinHttpSendRequest和WinHttpReceiveResponse之间的状态没有同步,前一个响应数据没读完就发起新请求,导致连接状态错乱。
解决方案有两个:要么保证每轮请求都把响应体完整读完再复用连接;要么干脆每次请求新建hConnect,性能上差一点但稳定性高。后来我读了WinHTTP文档,里面明确说连接句柄必须在完全处理完响应后才适合复用,如果不确定,就重建连接。很多“HTTP连接复用”类问题,根子都在这里,而不是网络本身。
6. 最后再分享一个调试小技巧
写到这儿,基本把两种实现的思路、代码、常见坑都过了一遍。我个人在后续项目里用得最多的技巧其实很简单:所有网络请求出口都加一个开关,打开后把URL、方法和响应状态码打日志,响应体只打到前几百字节。这个日志开关在联调和线上排查时救过我无数次,比任何抓包工具都直观。
另一个小技巧是:curl命令能跑通后,再把这个命令贴到代码注释里。这样三个月后回来看代码,你还能直接从注释里复现当时的请求参数,避免自己和自己打架。对接接口时,很多问题不是代码写错,而是文档和实现各说各话,把curl命令当作“接口行为说明书”,能省不少沟通成本。
本文还有配套的精品资源,点击获取