2026/10/26 18:26:30

HTTP/HTTPS 双实现:curl 与 WinHTTP 请求实战

HTTP/HTTPS 双实现:curl 与 WinHTTP 请求实战 简介面向需要在 Windows 平台实现 HTTP 与 HTTPS 请求的 C 开发者提供两套可对照的 GET/POST 封装方案基于 libcurl 的 CHttpClient 类与基于 WinHTTP 的 WinHttpClient 类适合学习网络请求封装、比较第三方库与系统 API 差异或为客户端项目快速选型。压缩包共 54 个文件约 6.24MB以头文件、静态库、cpp 源码为主并配有解决方案、工程配置与可执行示例便于直接编译调试和移植参考。已有 484 人学习读者可从中获取两类实现的接口设计、HTTPS 支持思路、库依赖配置及示例工程结构用于排查请求封装与链接配置中的常见问题。1. 从一次证书报错说起这份双实现资源到底装了什么服务端接口在浏览器里点得通换到 Windows 桌面端就报证书错误命令行 curl 能拉回的 JSON写进客户端却卡在 12175。类似的问题见多了我习惯把 HTTP HTTPS POST GET 拆成最小可复现的样例一边用 curl 命令行快速验证连通性和报文一边用 WinHTTP 在 Windows 平台上跑原生实现。这份资源就是把两条路径摆在一起对照覆盖同一组接口语义能逐项比较传输层差异。适合正在做客户端上报、后端联调或者不想引入第三方网络库的开发者拿来当基线比凭空造轮子稳得多。2. HTTP 与 HTTPS 的报文边界GET/POST 语义和两种实现的选型依据很多人把 GET 和 POST 的差别记成“一个把参数放 URL、一个放 body”这只对了一半。真正影响排查的是报文结构和中间设备的行为GET 的语义是安全且幂等中间缓存和重试机制默认可以重放POST 的语义是有副作用的一旦超时重发服务端可能真的写两次。这个差别在 curl 和 WinHTTP 里都绕不开所以先把协议层讲清楚再谈选型。2.1 GET 与 POST 在报文层面的真实差异一个 HTTP 请求报文由起始行、头部字段、空行和可选的消息体组成。GET 的起始行是GET /path?query HTTP/1.1参数放在请求目标里POST 的起始行通常只带路径参数放在 body 里并依赖Content-Type告诉服务端怎么解析。注意GET 带 body 并不是语法错误但很多代理、网关和缓存会直接丢弃 GET 的 body所以工程上不要这么用。HTTPS 则是在 HTTP 下面多了一层 TLS。TCP 三次握手之后先做 TLS 握手协商版本和密码套件、验证证书链、生成会话密钥之后才发送 HTTP 明文。对调用方来说GET/POST 的报文格式完全不变变化的是连接建立阶段和身份验证阶段。证书校验失败、SNI 不匹配、系统时间偏差都会让请求在发出 HTTP 报文之前就失败这也是为什么“浏览器能开、代码不能跑”的现象特别常见。从调试角度看GET 适合验证连通性和鉴权参数全在 URL 里用-v或抓包一眼能看全。POST 适合验证编码和长度Content-Type写错、Content-Length算错、分块传输和固定长度混用都会表现为 400 或 411。下面这张表是我做基线时固定记录的几个维度后面两章会反复用到。维度GETPOST参数位置请求目标 query string消息体幂等性是可安全重试否重试需幂等键常见长度限制受 URL 长度和网关限制受 body 大小和超时限制缓存行为可被中间层缓存默认不缓存排查重点编码、鉴权头、重定向Content-Type、长度、超时2.2 curl 还是 WinHTTP几种场景下的选择依据curl 的优势是跨平台、行为一致、命令行和库两种形态可以互相验证。命令行版适合先跑通请求再把同一组参数翻译成 libcurl 调用libcurl 则适合需要精细控制超时、重定向、证书路径和连接复用的场景。它的坑在于依赖外部 CA 证书文件不同系统上的默认路径不同静态链接时还要注意编译选项。WinHTTP 是 Windows 原生 API不额外引入运行时依赖能直接复用系统的证书存储和代理配置。适合做 Windows 桌面客户端、安装包体积敏感、或者公司环境不允许带第三方网络库的情况。它的代价是 API 分层多、错误码分散而且只能在 Windows 上跑跨平台项目通常要再包一层抽象。我一般的判断顺序是先看目标平台是否只有 Windows是的话优先 WinHTTP需要跨平台或者要跟命令行输出对齐选 curl。两者都支持 HTTPS也都能做 GET/POST真正的差别在证书来源、代理行为和错误信息的可读性上。选型定下来之后先写最小请求闭环再补超时和重试不要一上来就封装成通用客户端。3. curl 落地命令行验证和 libcurl 编程的两套写法curl 的价值在于“先用命令行把请求跑通再把同样的参数搬进代码”。命令行阶段能快速排除服务端问题代码阶段只关注参数映射和错误处理。两套写法共用同一组语义所以排查时可以互相印证。3.1 用 curl 命令行把 GET/POST 跑成基线先准备两个文件headers.txt存响应头body.bin存响应体。把头部和体分开后面看状态码、Content-Type、Content-Length会方便很多。下面这条 GET 请求带上了鉴权头、连接超时和总超时并把响应拆到两个文件。# GET 基线响应头与响应体分开落盘便于核对状态码和长度 curl -sS -D headers.txt -o body.bin \ -H Accept: application/json \ -H Authorization: Bearer $TOKEN \ --connect-timeout 5 \ --max-time 20 \ https://api.example.test/v1/items?page1size20-sS表示静默但保留错误输出-D把响应头写到文件-o把响应体写到文件。--connect-timeout只约束 TCP 和 TLS 握手阶段--max-time约束整个请求。这两个参数要分开设否则一个慢接口会把连接超时的作用掩盖掉。$TOKEN从环境变量读避免把凭据写进命令历史。POST 的基线写法如下重点是--data-binary和Content-Type要匹配。用--data-binary而不是-d是因为后者会去掉换行二进制或格式化 JSON 容易被改坏。# POST 基线body 从文件读显式声明 Content-Type curl -sS -D headers.txt -o body.bin \ -X POST https://api.example.test/v1/items \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN \ --data-binary payload.json \ --connect-timeout 5 \ --max-time 30跑完之后先看headers.txt第一行确认状态码再看Content-Type和Content-Length是否符合预期最后看body.bin是不是合法 JSON 或预期格式。这一步不要跳过很多“代码有问题”其实是服务端返回了 302 或 401被客户端当成成功解析了。HTTPS 场景下如果报证书错误先确认系统时间、再确认 CA 证书路径。Linux 上常见的是/etc/ssl/certs/ca-certificates.crt可以用--cacert显式指定。只有在确认是自签测试环境时才考虑关闭校验生产环境不要用-k绕过。# 显式指定 CA 证书避免依赖不同系统的默认路径 curl -sS --cacert /etc/ssl/certs/ca-certificates.crt \ -D headers.txt -o body.bin \ https://api.example.test/v1/health3.2 libcurl 接口封装与 HTTPS 证书校验命令行跑通之后把参数映射到 libcurl。下面这段代码发一个 JSON POST把响应写到文件并打印状态码。关键点是设置写回调、设置超时、显式指定 CA 路径以及用CURLINFO_RESPONSE_CODE拿状态码而不是只看返回值。#include curl/curl.h #include stdio.h #include string.h /* 写回调把响应体原样写入文件不做任何解析 */ static size_t on_write(char *ptr, size_t size, size_t nmemb, void *userdata) { size_t n size * nmemb; FILE *fp (FILE *)userdata; return fwrite(ptr, 1, n, fp); } int main(void) { CURL *curl curl_easy_init(); if (!curl) return 1; FILE *fp fopen(response.bin, wb); if (!fp) { curl_easy_cleanup(curl); return 1; } struct curl_slist *headers NULL; headers curl_slist_append(headers, Content-Type: application/json); /* 禁用 Expect: 100-continue避免小请求多一次往返 */ headers curl_slist_append(headers, Expect:); const char *json {\name\:\demo\,\value\:42}; curl_easy_setopt(curl, CURLOPT_URL, https://api.example.test/v1/items); curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json); curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(json)); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_write); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_CAINFO, /etc/ssl/certs/ca-certificates.crt); /* 跟随重定向时限制次数避免循环 */ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); CURLcode rc curl_easy_perform(curl); long status 0; if (rc CURLE_OK) { curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status); printf(HTTP status: %ld\n, status); } else { fprintf(stderr, curl error: %s\n, curl_easy_strerror(rc)); } fclose(fp); curl_slist_free_all(headers); curl_easy_cleanup(curl); return rc CURLE_OK ? 0 : 2; }几个参数值得单独说。CURLOPT_POSTFIELDSIZE要显式给长度否则 libcurl 会对字符串做strlen遇到内嵌零字节会截断。CURLOPT_CAINFO不设时依赖编译时的默认路径换一台机器就可能失效所以建议显式给。CURLOPT_FOLLOWLOCATION打开后POST 遇到 301/302 默认可能被改成 GET需要业务自己判断是否接受敏感写操作最好关掉重定向。Expect:那个空头部是血泪经验部分服务端对Expect: 100-continue处理不完整小请求加它反而多一次往返。编译时链接 libcurlLinux 上通常是-lcurlWindows 上可以用静态库或动态库注意运行时 DLL 要随程序分发。如果返回CURLE_SSL_CACERT或CURLE_PEER_FAILED_VERIFICATION先对照命令行的--cacert行为两边应该一致不一致就说明证书路径或系统时间有一边不对。4. WinHTTP 落地会话/连接/请求三级句柄与 TLS 请求WinHTTP 的代码量比 libcurl 多但结构固定开会话、连主机、开请求、发请求、收响应、读数据、逐级关句柄。把这条链路走通一次后面改方法、改头部、改 HTTPS 标志都是在这个骨架上换参数。4.1 WinHTTP 的句柄模型与最小请求闭环WinHTTP 用三级句柄管理资源。WinHttpOpen创建会话承载代理和超时配置WinHttpConnect绑定目标主机和端口不涉及具体路径WinHttpOpenRequest指定方法、路径和是否走 TLS。第三级的WINHTTP_FLAG_SECURE就是 HTTPS 开关漏掉它就会以明文访问 443 端口表现为连接被重置或返回奇怪的状态码。下面是最小 GET 请求的骨架重点看句柄关闭顺序和错误码打印。WinHTTP 的错误码用GetLastError()取常见值后面会单独讲。#include windows.h #include winhttp.h #include stdio.h #pragma comment(lib, winhttp.lib) int main() { /* 会话默认代理配置超时单位是毫秒 */ HINTERNET hSession WinHttpOpen(LDemoClient/1.0, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (!hSession) { printf(Open failed: %lu\n, GetLastError()); return 1; } WinHttpSetTimeouts(hSession, 5000, 5000, 10000, 20000); /* 连接HTTPS 端口用 INTERNET_DEFAULT_HTTPS_PORT */ HINTERNET hConnect WinHttpConnect(hSession, Lapi.example.test, INTERNET_DEFAULT_HTTPS_PORT, 0); if (!hConnect) { printf(Connect failed: %lu\n, GetLastError()); return 2; } /* 请求第三个参数是路径第五个参数决定是否走 TLS */ HINTERNET hRequest WinHttpOpenRequest(hConnect, LGET, L/v1/items, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); if (!hRequest) { printf(OpenRequest failed: %lu\n, GetLastError()); return 3; } BOOL ok WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0); if (!ok) { printf(Send failed: %lu\n, GetLastError()); return 4; } ok WinHttpReceiveResponse(hRequest, NULL); if (!ok) { printf(Receive failed: %lu\n, GetLastError()); return 5; } /* 查询状态码用 NUMBER 标志直接拿整数 */ DWORD status 0, len sizeof(status); WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, status, len, WINHTTP_NO_HEADER_INDEX); printf(HTTP status: %lu\n, status); /* 循环读取响应体直到可用数据为 0 */ DWORD avail 0; do { avail 0; if (!WinHttpQueryDataAvailable(hRequest, avail)) break; if (avail 0) break; char *buf new char[avail 1]; DWORD read 0; if (WinHttpReadData(hRequest, buf, avail, read)) { buf[read] 0; fwrite(buf, 1, read, stdout); } delete[] buf; } while (avail 0); WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return 0; }WinHttpSetTimeouts的四个参数依次是解析超时、连接超时、发送超时、接收超时单位都是毫秒。不设的话会走系统默认某些场景下默认值偏长表现为程序“卡住”。句柄关闭顺序要和创建顺序相反漏关不会立刻报错但长时间运行会泄漏。4.2 带 HTTPS 的 POST 发送与响应读取POST 和 GET 的差别集中在WinHttpOpenRequest的方法参数、WinHttpSendRequest的请求体和头部。下面这段在骨架上改成 POST带Content-Type和一个 JSON 体。注意头部用宽字符结尾要带\r\n长度参数用-1L表示以空字符结尾。const char *json {\name\:\demo\,\value\:42}; const wchar_t *extraHeaders LContent-Type: application/json\r\n; HINTERNET hRequest WinHttpOpenRequest(hConnect, LPOST, L/v1/items, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); BOOL ok WinHttpSendRequest(hRequest, extraHeaders, -1L, (LPVOID)json, (DWORD)strlen(json), (DWORD)strlen(json), 0); if (!ok) { printf(Send failed: %lu\n, GetLastError()); return 4; } ok WinHttpReceiveResponse(hRequest, NULL); if (!ok) { printf(Receive failed: %lu\n, GetLastError()); return 5; }WinHttpSendRequest的第三个参数是头部长度-1L表示自动计算第五个参数是请求体长度要显式给字节数不能用-1L第六个参数是总长度通常和请求体长度一致。如果服务端返回 411优先检查这里是不是传了 0 或长度不匹配。如果返回 400检查Content-Type是否拼写正确、JSON 是否合法。HTTPS 的证书校验默认由系统证书存储完成不需要额外指定 CA 文件这也是 WinHTTP 在 Windows 环境下的便利之处。如果遇到证书错误先看错误码是不是 12175含义是证书链或主机名校验失败。常见原因包括系统时间偏差、目标站点证书链不完整、代理替换了证书。不要第一时间去关校验先确认环境。读响应体的循环和 GET 一样但要注意大响应分多次WinHttpReadData返回不能假设一次读完。如果响应是 gzip 压缩的WinHTTP 不会自动解压需要自己看Content-Encoding再决定是否解压。这一点和 curl 的--compressed不同容易翻车。5. 常见问题排查五个从现象倒推原因的坑下面五条是我在两种实现之间来回对照时踩过的按“现象 → 原因 → 解决”记录。每条都尽量给可验证的动作而不是只给结论。5.1 现象浏览器正常客户端报证书错误现象是同一台机器上浏览器能打开接口curl 或 WinHTTP 却报证书校验失败。原因通常是客户端使用的证书存储和浏览器不一致curl 依赖 CA 文件路径WinHTTP 依赖系统存储而浏览器可能用了自己的信任列表或企业策略注入的根证书。另一个高频原因是系统时间偏差证书的生效时间和当前时间对不上。解决分三步先用date或系统设置确认时间准确再用 curl 的--cacert显式指向系统 CA 文件看是否恢复WinHTTP 场景下检查错误码如果是 12175用系统证书管理工具确认目标根证书是否存在。确认是自签测试环境后WinHTTP 可以通过WinHttpSetOption配合WINHTTP_OPTION_SECURITY_FLAGS放宽校验但生产环境不要这么做应该把根证书装进受信任存储。5.2 现象POST 大文件被 413 拒绝现象是小 JSON 能发换成几 MB 的文件就返回 413。原因不在客户端库而在链路上的限制反向代理的请求体上限、服务端框架的上限、或者Content-Length计算错误导致服务端提前拒绝。curl 和 WinHTTP 本身不会限制请求体大小但它们对长度的处理方式不同容易在边界上出问题。解决时先用小文件复现逐步增大直到失败定位是哪个环节拒绝。curl 用--data-binary file并检查Content-Length是否正确WinHTTP 检查WinHttpSendRequest的长度参数是否用了strlen而不是实际字节数二进制文件里如果有零字节会被截断。超过代理上限时要么调整服务端配置要么改用分片上传不要指望客户端库能绕过服务端限制。5.3 现象超时重试导致重复写入现象是接口偶尔超时客户端加了重试后服务端出现重复数据。原因是 POST 不是幂等操作超时只代表客户端没等到响应不代表服务端没执行。curl 的--retry和手写的重试循环都有这个问题WinHTTP 场景下如果自己包重试逻辑也一样。解决分两层服务端提供幂等键客户端每次重试带同一个键客户端侧只对连接失败和明确的 5xx 做重试对超时要先查询再决定是否重发。curl 可以用--retry配合--retry-max-time限制总时长但幂等性仍然要靠业务保证。WinHTTP 没有内置重试自己写循环时要把请求体重新构造一遍不能复用已经被读过的缓冲区。5.4 现象WinHTTP 返回 12175现象是 WinHTTP 请求 HTTPS 接口直接失败GetLastError()返回 12175。这个错误码对应证书链或主机名校验失败常见原因有三类系统时间不对、目标证书链不完整、代码里WinHttpOpenRequest的服务器名和证书里的 SAN 不匹配。还有一种容易被忽略的情况是代理替换了证书而代理的根证书没有装进系统存储。解决时先确认系统时间和时区再用浏览器打开同一地址看证书链是否完整然后检查代码里的主机名有没有多余的空格或大小写问题。如果确认是自签测试证书可以把根证书导入“受信任的根证书颁发机构”而不是在代码里关校验。确实需要临时放宽时用WinHttpSetOption设置安全标志并明确标注只在测试构建里生效。5.5 现象压缩响应体解出乱码现象是请求头里带了Accept-Encoding: gzip拿到的响应体直接按文本解析是乱码。原因是服务端返回了 gzip 压缩的内容curl 命令行需要--compressed才会自动解压libcurl 默认不解压WinHTTP 也不解压。三者行为不一致很容易在切换实现时翻车。解决时先看响应头里的Content-Encoding确认是不是 gzip 或 br。curl 命令行加--compressedlibcurl 设置CURLOPT_ACCEPT_ENCODING为空字符串让库自动协商并解压WinHTTP 则要自己判断并调用解压库或者干脆不发送Accept-Encoding让服务端返回明文。不要在不看响应头的情况下假设响应体一定是 JSON 文本。6. 验证与收尾用三个检查点确认请求真的通了请求跑通不等于业务可用我通常用三个检查点收尾状态码、响应头、响应体。状态码只看 2xx 不够要区分 200、201、204 的语义响应头重点看Content-Type、Content-Length和重定向相关字段响应体要按声明的类型解析JSON 用解析器验证二进制比对长度和哈希。6.1 状态码、响应头、响应体三段验证下面这段脚本把 curl 的输出拆成三段检查适合放进联调脚本里。-w只取状态码-D取头部-o取体三个文件分开看避免混在一起。参数里保留连接超时和总超时和正式客户端保持一致。# 三段验证状态码、响应头、响应体分别落盘 curl -sS -o body.bin -D headers.txt \ -w %{http_code}\n \ --connect-timeout 5 --max-time 20 \ https://api.example.test/v1/health status.txt # 状态码必须是 2xx grep -qE ^2[0-9][0-9]$ status.txt || { echo bad status: $(cat status.txt); exit 1; } # 响应头里确认内容类型 grep -iE ^content-type: headers.txt || { echo missing content-type; exit 1; } # 响应体非空 [ -s body.bin ] || { echo empty body; exit 1; }WinHTTP 侧没有现成的-w但可以用同样的思路WinHttpQueryHeaders取状态码和Content-TypeWinHttpReadData累计字节数最后比对预期。两边都跑一遍能快速发现“命令行通、代码不通”的差异点。6.2 超时与重试参数的基线表超时和重试不要拍脑袋我一般先用下表做基线再按实际网络调整。curl 和 WinHTTP 的参数名不同但语义可以对齐。控制点curl 参数WinHTTP 对应建议基线连接超时--connect-timeoutWinHttpSetTimeouts第二参数5 秒总超时--max-timeWinHttpSetTimeouts第四参数20 到 30 秒重定向次数--max-redirsWinHttpSetOption策略3 次以内重试次数--retry自写循环仅连接失败重试压缩协商--compressed手动处理按响应头判断这张表我每次接入新接口都会填一遍填完再写代码。有一次赶进度跳过了这一步结果客户端在弱网下反复超时重试服务端日志里出现成对的写请求排查了半天才定位到是重试策略没和后端幂等键对齐。从那以后我每次接新接口都强制走一遍“命令行基线 → 三段验证 → 超时表”的流程宁可前期慢一点也不在线上补后悔药。希望帮到你。本文还有配套的精品资源点击获取