libcurl/WinHTTP 实现 HTTPS GET/POST 客户端
简介面向Windows平台C网络编程学习者的HTTP/HTTPS请求实现资料包围绕GET与POST两类常用请求给出libcurl与WinHTTP两条技术路线的可编译工程。前者用CHttpClient类封装HTTP、HTTPS的GET与POST调用后者由WinHttpClient类完成同等功能便于对照两种底层库在接口设计、连接管理与错误处理上的差异适合需要为客户端程序加入网络模块或在课程设计、面试准备中演示请求流程的初中级开发者。工程将请求参数、回调与结果返回集中到少量类接口中便于直接嵌入现有项目也可作为比较两种实现代码风格的参考样本。压缩包共54个文件、约6.24MB以17个.h头文件、4个.cpp源文件和12个.lib依赖库为主配合.sln解决方案、.vcxproj工程、manifest清单与说明文档构成可直接打开编译的完整Visual Studio工程省去手工配置库路径的麻烦。已有484人学习下载可据此跑通HTTPS请求、排查证书与链接错误并在此基础上扩展超时、代理、响应解析等功能。1. 需求只有一句话GET 拉配置、POST 传 JSON但它得跑在别人的机器上一个 Windows 客户端要对接自建服务需求表上就两行字用 GET 拉一次配置用 POST 传一段 JSON全程 HTTPS。真动手才发现难点不在协议本身而在于它得跑在别人的机器上——没有开发环境、没有网络诊断工具、双击就得出结果。这份资源把同一件事用两条技术路线各写了一遍libcurl 版本和 WinHTTP 版本GET/HTTPS/POST 的完整请求、超时、证书处理和错误码都是可编译的实现。适合写 Windows C 客户端、做内网小工具、需要把单个 exe 直接发出去的人也适合手里已经有一份能跑的 curl 代码、却被静态链接和证书问题绊住的同行。下面按「选型—curl 实现—WinHTTP 实现—踩坑—验证」的顺序往下拆。2. 选 libcurl 还是 WinHTTP先把部署形态和功能账算清楚这两条路最容易被简化成一句「curl 跨平台、WinHTTP 免安装」但真正决定你半个月后会不会返工的是分发形态、功能覆盖和线程模型这三件事。同一份需求选择不同路线代码量能差出三倍出问题时的排查路径也完全不同。2.1 分发形态决定了第一道门槛libcurl 的接入方式有三种直接用官方预编译 DLL、链静态库、把源码拉进工程一起编。第一种最省事但发版时 exe 旁边必须躺着libcurl.dll还得确认目标机器上有没有对应的 VC 运行库第二种只出一个 exe代价是编译期要摆平运行库一致性——你的工程用/MD静态库却是/MT编出来的链接阶段不报错运行到curl_easy_init才崩这类问题查起来相当费时间。第三种适合要裁剪功能比如只留 HTTP、去掉 FTP 和 Telnet的场景配置项多但产物最干净。静态链接 libcurl 时除了libcurl.lib本身还要按后端补链接系统库。走 Schannel 后端时常见的是ws2_32.lib、crypt32.lib、wldap32.lib、normaliz.lib少一个就是一堆unresolved external symbol。用 vcpkg 的话这些依赖它会帮你记着手动配的人最好把链接器命令行完整保存一份换台机器重编时能省掉半天。WinHTTP 这边简单得多#pragma comment(lib, winhttp.lib)导入库是 SDK 自带的运行时是系统组件exe 体积零增量。代价是只能在 Windows 上跑而且行为跟着系统版本走——同一份代码在旧系统和新系统上支持的 TLS 版本、可用的协议特性都可能不一样做兼容性测试时得把目标系统的最低版本明确写进验收条件。所以选型第一刀切在这里产品要跨平台、或者要精确控制 HTTP 行为选 libcurl产品是纯 Windows、安装包体积敏感、不想带任何第三方二进制选 WinHTTP。两条都要就把它们藏在同一个接口后面编译期切换。2.2 功能清单对撞哪些能力是自带的哪些要自己写把常见需求列成表对一遍差距比想象中大能力libcurlWinHTTP自动解压 gzip/deflateCURLOPT_ACCEPT_ENCODING传空串即开启不自动解压必须自己处理或干脆不声明跟随 3xx 重定向CURLOPT_FOLLOWLOCATION一行需自己读Location头再发一次Cookie 保持内置 cookie 引擎可落盘手动拼Cookie头multipart 表单上传CURLOPT_MIMEPOST直接构造手拼 boundary容易漏\r\n证书校验来源默认读编译时指定的 bundle可用CURLOPT_CAINFO覆盖走系统证书存储无需带文件连接复用easy 句柄复用即复用连接同一HINTERNET连接句柄下复用超时粒度连接超时、总超时两档解析、连接、发送、接收四段这张表里最坑的两行是「自动解压」和「重定向」。curl 声明了Accept-Encoding就会自动解压你拿到的是明文WinHTTP 老老实实把压缩后的字节给你如果服务端看客户端发了Accept-Encoding: gzip就压缩返回而你直接当文本解析结果就是一屏乱码。重定向同理curl 一个开关搞定WinHTTP 遇到 302 拿到的是空 body 加一个Location不自己再发一次就永远拿不到数据。2.3 把两条路塞进同一个接口一个结构体加两个实现文件我一般会先定一个与实现无关的返回结构两个实现文件各自填它。这样上层业务代码只依赖这个结构换实现时不用动业务逻辑。// http_types.h —— 上层只认这个结构不认 curl 也不认 WinHTTP #pragma once #include string struct HttpResult { long status 0; // HTTP 状态码0 表示没拿到响应 std::string body; // 响应体原文 std::string headers; // 原始响应头排查问题时很有用 long error_code 0; // 传输层错误curl 是 CURLcodeWinHTTP 是 GetLastError std::string error_text; // 人眼可读的错误描述 bool ok() const { return error_code 0 status 200 status 300; } }; struct HttpRequest { std::string url; // 完整 URL含 scheme std::string method GET; // GET / POST / PUT ... std::string body; // POST 时填 std::string content_type application/json; long connect_timeout_ms 3000; long total_timeout_ms 15000; std::string ca_path; // 仅 curl 侧使用空则用默认 }; HttpResult HttpDo(const HttpRequest req); // 由编译宏决定链接哪个实现status和error_code必须分开HTTP 500 是一次成功的传输拿到了响应CURLE_COULDNT_CONNECT才是没连上。把这两类失败混成一个 bool上层就没法决定是重试还是提示用户检查网络。headers留着是为了排查——很多「服务端说返回了但客户端没收到」的问题答案就藏在Content-Length和Transfer-Encoding这两行里。3. 用 curl 打通 GET 与 POST一次请求只差三个 setoptcurl 的 easy 接口是个状态机建句柄、设参数、执行、销毁。绝大多数翻车都发生在「设参数」这一步——参数名对、类型错或者指针指向的内存提前没了。3.1 全局初始化与 easy 句柄的四段式curl_global_init必须在任何 curl 函数之前调用而且全进程只调一次。多线程环境里我习惯用std::call_once包住别在每个线程里各调一遍也别在某个线程退出时调curl_global_cleanup那会让其他线程手里的句柄全部失效。// http_curl.cpp —— 会话级初始化与回调 #include curl/curl.h #include mutex #include string #include vector namespace { std::once_flag g_init_once; // 写回调curl 可能分多次把响应体喂进来必须自己拼 size_t OnWrite(char* ptr, size_t size, size_t nmemb, void* userdata) { const size_t n size * nmemb; // 真实字节数是两者相乘 static_caststd::string*(userdata)-append(ptr, n); return n; // 返回值不等于 n 会被判为写失败 } size_t OnHeader(char* ptr, size_t size, size_t nmemb, void* userdata) { const size_t n size * nmemb; static_caststd::string*(userdata)-append(ptr, n); return n; } } // namespacesize * nmemb这个乘法不是形式主义curl 的约定是「nmemb 个大小为 size 的块」早期有人直接返回nmemb在 body 较大的情况下会触发CURLE_WRITE_ERROR错误码 23现象是「请求发出去了但返回内容是空的」。回调返回值和实际写入字节数不一致时curl 会认为你处理不了这么多数据直接中断传输。3.2 GET 的骨架URL、超时、重定向三件套HttpResult HttpGetCurl(const HttpRequest req) { HttpResult out; std::call_once(g_init_once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); CURL* h curl_easy_init(); if (!h) { out.error_text curl_easy_init failed; return out; } curl_easy_setopt(h, CURLOPT_URL, req.url.c_str()); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, OnWrite); curl_easy_setopt(h, CURLOPT_WRITEDATA, out.body); curl_easy_setopt(h, CURLOPT_HEADERFUNCTION, OnHeader); curl_easy_setopt(h, CURLOPT_HEADERDATA, out.headers); curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L); // 跟随 3xx curl_easy_setopt(h, CURLOPT_MAXREDIRS, 5L); // 防止重定向环 curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT_MS, req.connect_timeout_ms); curl_easy_setopt(h, CURLOPT_TIMEOUT_MS, req.total_timeout_ms); curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); // 多线程下必须打开 curl_easy_setopt(h, CURLOPT_ACCEPT_ENCODING, ); // 空串接受并自动解压 const CURLcode rc curl_easy_perform(h); if (rc CURLE_OK) { curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, out.status); } else { out.error_code static_castlong(rc); out.error_text curl_easy_strerror(rc); } curl_easy_cleanup(h); return out; }几个参数值得单独说。CURLOPT_TIMEOUT_MS是整次请求的总时限包含 DNS 解析、TCP 连接、TLS 握手和传输CURLOPT_CONNECTTIMEOUT_MS只管连接阶段。两个都设才能既保证连不上时快速失败又保证连上以后不会因为服务端迟迟不返回而挂死。CURLOPT_NOSIGNAL在多线程程序里必须置 1否则 curl 用alarm实现超时会干扰其他线程的信号处理表现是随机崩溃或超时不准。CURLOPT_ACCEPT_ENCODING传空串意味着「我接受你支持的所有压缩格式」并且由 curl 负责解压——注意解压是自动的但Content-Length头仍然是压缩后的大小不要拿它去校验解压后的 body 长度。3.3 POST 的三处改动POSTFIELDS、长度、Content-Type从 GET 切到 POST本质上只改三个地方但每一处都有坑。HttpResult HttpPostCurl(const HttpRequest req) { HttpResult out; std::call_once(g_init_once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); CURL* h curl_easy_init(); if (!h) { out.error_text curl_easy_init failed; return out; } struct curl_slist* hdrs nullptr; const std::string ct Content-Type: req.content_type; hdrs curl_slist_append(hdrs, ct.c_str()); hdrs curl_slist_append(hdrs, Expect:); // 关掉 100-continue小包少一个往返 curl_easy_setopt(h, CURLOPT_URL, req.url.c_str()); curl_easy_setopt(h, CURLOPT_POST, 1L); curl_easy_setopt(h, CURLOPT_POSTFIELDS, req.body.data()); // 不拷贝只存指针 curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE_LARGE, static_castcurl_off_t(req.body.size())); // 二进制安全 curl_easy_setopt(h, CURLOPT_HTTPHEADER, hdrs); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, OnWrite); curl_easy_setopt(h, CURLOPT_WRITEDATA, out.body); curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT_MS, req.connect_timeout_ms); curl_easy_setopt(h, CURLOPT_TIMEOUT_MS, req.total_timeout_ms); curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(h, CURLOPT_ACCEPT_ENCODING, ); const CURLcode rc curl_easy_perform(h); if (rc CURLE_OK) curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, out.status); else { out.error_code static_castlong(rc); out.error_text curl_easy_strerror(rc); } if (hdrs) curl_slist_free_all(hdrs); // 必须在 perform 之后释放 curl_easy_cleanup(h); return out; }CURLOPT_POSTFIELDS只保存指针不复制内容这是最典型的悬垂指针来源。如果你的写法是curl_easy_setopt(h, CURLOPT_POSTFIELDS, BuildJson().c_str())临时std::string在分号处就析构了perform读到的是已经释放的内存——有时能跑通有时发出去一串垃圾属于典型的玄学问题。另外一定要设POSTFIELDSIZE_LARGE不设的话 curl 用strlen算长度body 里带\0的二进制数据会被截断传 protobuf 或图片时必然翻车。Expect:这个空头是为了关掉100-continue。curl 在 body 较大时会先发一个Expect: 100-continue等服务端确认某些服务端或中间层不认这个头客户端就要干等到超时。body 在几十 KB 以内的场景直接关掉更省事。3.4 HTTPS 不是加个 sCA bundle、校验开关与后端选择HTTP 改 HTTPS代码几乎不用动但证书链的验证逻辑会立刻开始工作。Windows 上最常遇到的是错误码 60CURLE_PEER_FAILED_VERIFICATION意思是「证书链验证不通过」。// HTTPS 相关的三个参数放在 perform 之前 curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, 1L); // 校验证书链默认就是 1别关 curl_easy_setopt(h, CURLOPT_SSL_VERIFYHOST, 2L); // 2 才校验域名与证书是否匹配 if (!ca_path.empty()) { curl_easy_setopt(h, CURLOPT_CAINFO, ca_path.c_str()); // 指向 cacert.pem }CURLOPT_SSL_VERIFYHOST的值只有 0 和 2 有定义传 1 在较新的版本里等价于 2但别依赖这个行为写 2 最明确。想「先让它跑通」而把VERIFYPEER关掉等于把中间人攻击的门留着这种代码一旦进了主干就很难再改回来——我见过太多工程里这一行注释着「TODO 上线前打开」然后一直留到产品下线。CA 来源取决于 libcurl 编译时的 SSL 后端。走 OpenSSL 的话必须自带一份cacert.pem并显式指定路径程序拷到别的机器上找不到这个文件就会报 77CURLE_SSL_CACERT_BADFILE走 Schannel 后端则直接用系统证书存储不需要额外文件。判断自己手里是哪种最直接的办法是curl_version()打印出来的features和ssl_version字段。命令行版本可以当对照工具用同一台机器上命令行能通、代码里不通问题基本就在参数上# 对照实验命令行能通的请求代码里照抄参数 curl -sS -v https://api.example.com/v1/config \ --connect-timeout 3 --max-time 15 \ --cacert ./ca/cacert.pem \ -w \nHTTP %{http_code} total %{time_total}s\n-v会把 TLS 握手、证书链、请求头、响应头全打出来-w里的time_total能告诉你时间花在了连接还是传输。这两个组合起来比在代码里加十行日志都快。4. WinHTTP 侧落地六个 API 拼出一次完整请求WinHTTP 没有「一次调用完成请求」的接口它的模型是四层句柄逐级打开会话、连接、请求最后是响应数据。层级多带来的是控制力代价是每一步都要判错、每一层都要关。4.1 会话与连接超时要在 Open 之后立刻设会话句柄session承载全局配置连接句柄connect绑定主机和端口。主机名必须是宽字符且不能带https://前缀和路径这是新手第一个卡点。// http_winhttp.cpp —— 会话、连接与超时 #include windows.h #include winhttp.h #include string #pragma comment(lib, winhttp.lib) // 访问类型取值在 winhttp.h 里 WinHttpOpen 的第二个参数能看到候选枚举 // 桌面工具走直连那一档即可不要留空指针 static const DWORD kAccessType WINHTTP_ACCESS_TYPE_NO_PROXY; HINTERNET OpenSession() { HINTERNET hSession WinHttpOpen(Ldemo-client/1.0, kAccessType, nullptr, nullptr, 0); if (!hSession) return nullptr; // 四段超时解析、连接、发送、接收单位毫秒子句柄继承 WinHttpSetTimeouts(hSession, 3000, // DNS 解析 3000, // TCP 连接 5000, // 发送 10000); // 接收 return hSession; }WinHttpSetTimeouts这四个参数不设的话默认值相当宽松接收超时尤其长表现就是「程序像死了但其实在等」。它设在会话上之后打开的连接和请求都继承所以放在WinHttpOpen之后的第一行最合适。会话句柄可以在多个线程间共享但并发请求各自的请求句柄必须分开别一个句柄多个线程一起用。4.2 OpenRequest动词、路径与安全标志请求句柄决定「用什么方法、访问哪个路径、走不走加密」。路径部分必须是已经做百分号编码的纯 ASCII再转成宽字符——直接把含中文的std::string逐字节塞进宽字符串服务端收到的会是乱码。// 把 UTF-8 路径编码成纯 ASCII再逐字节扩展为宽字符 static std::wstring EncodePath(const std::string raw) { static const char* kKeep -_.~/?; std::string out; for (unsigned char c : raw) { const bool plain (c 0 c 9) || (c A c Z) || (c a c z) || (c strchr(kKeep, c)); if (plain) { out.push_back(static_castchar(c)); } else { char buf[8]; sprintf_s(buf, sizeof(buf), %%%02X, c); out buf; } } std::wstring w; w.reserve(out.size()); for (char ch : out) w.push_back(static_castwchar_t(static_castunsigned char(ch))); return w; } HINTERNET OpenRequest(HINTERNET hConnect, const std::wstring method, const std::string utf8_path, bool secure) { const std::wstring path EncodePath(utf8_path); HINTERNET hRequest WinHttpOpenRequest( hConnect, method.c_str(), path.c_str(), nullptr, // 不指定 HTTP 版本用默认 WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0); if (hRequest secure) { // 显式打开 TLS 1.2/1.3避免落在系统默认的低版本上 DWORD protos WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2 | WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3; WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURE_PROTOCOLS, protos, sizeof(protos)); } return hRequest; }WINHTTP_FLAG_SECURE是 https 的开关忘了它就会用 80 端口明文发出去服务端直接断开错误码看起来像是连接问题。WINHTTP_OPTION_SECURE_PROTOCOLS这一行在很多老代码里没有默认协议集在旧系统上可能只到 TLS 1.0对端要求 1.2 时握手失败报ERROR_WINHTTP_SECURE_FAILURE0x80072F8F。加上这一行等于把最低要求钉死。4.3 发与收SendRequest 的两个长度参数最容易错WinHttpSendRequest有五个和长度相关的参数签名里dwHeadersLength、dwOptionalLength、dwTotalLength三个都是 DWORD填错任何一个都可能让请求发出去但 body 为空。BOOL SendJson(HINTERNET hRequest, const std::string utf8_body, const std::wstring content_type) { std::wstring headers LContent-Type: content_type L\r\n; return WinHttpSendRequest( hRequest, headers.c_str(), static_castDWORD(-1), // -1 表示头部以 \0 结尾 utf8_body.empty() ? nullptr : (LPVOID)utf8_body.data(), static_castDWORD(utf8_body.size()), // dwOptionalLength static_castDWORD(utf8_body.size()), // dwTotalLength必须一致 0); }第三个参数dwHeadersLength传-1是官方约定的「字符串以空字符结尾你自己算」最省事。第四、五个参数是重点dwOptionalLength是「本次调用顺带发送的字节数」dwTotalLength是「整个请求体的总长度」。一次性发完的场景两个都填body.size()如果分多次发先SendRequest发头部再WinHttpWriteData追加第一次的dwTotalLength要填最终总长度dwOptionalLength填本次实际带的字节。很多人只填了前一个后一个填 0 或 -1结果服务端读到的Content-Length是 0body 凭空消失。还有一个细节body.data()返回的是const char*这里转成LPVOID是因为 API 签名用了非 const 指针但 WinHTTP 在同步模式下不会修改这块内存。如果你用的是异步模式带WINHTTP_FLAG_ASYNC的会话这个缓冲区必须活到回调触发为止不能是栈上的临时对象。4.4 读响应体QueryDataAvailable 加 ReadData 的循环响应不是一次读完的。WinHttpReadData一次只给你当前可用的那部分必须循环到「可用字节数为 0」为止同时状态码要在WinHttpReceiveResponse之后才能查。HttpResult ReadResponse(HINTERNET hRequest) { HttpResult out; if (!WinHttpReceiveResponse(hRequest, nullptr)) { out.error_code GetLastError(); return out; } 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); out.status static_castlong(status); for (;;) { DWORD avail 0; if (!WinHttpQueryDataAvailable(hRequest, avail)) { out.error_code p a hrefhttps://download.csdn.net/download/qq_29831481/10331439 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p