1. 为什么还要折腾 VC/MFC 下的 HTTPS 请求现在做 Windows 桌面端开发尤其是维护老项目的兄弟大概率都遇到过这个场景一个跑了十来年的 MFC 程序之前一直用 WinINet 或者 WinHTTP 发 HTTP 请求日子过得挺安稳。突然有一天服务端那边把接口全切到 HTTPS 了或者对接的第三方平台只给 HTTPS 接口这时候你手里的 MFC 程序就尴尬了——要么请求直接失败要么返回一堆乱码要么干脆卡死在那儿。我前阵子接手的一个工控上位机项目就是这种情况。整个界面框架是 MFC 写的用了十几年了底层通信模块还是当年用 CInternetSession 封装的一套东西。服务端升级之后所有接口强制 HTTPS老板要求一周内搞定。当时第一反应是找现成的库但项目编译环境是 VS2010C11 都不完整支持很多现代网络库根本编译不过去。最后走了一圈还是回到 WinHTTP 这条路上来。这篇文章就是把这个过程完整记录下来。VC/MFC 下的 HTTPS 请求核心要解决的无非这么几件事怎么发起请求、怎么处理证书、怎么保证不阻塞界面、怎么处理各种编码和超时。我会从方案选型开始讲把 WinHTTP 和 WinINet 的取舍逻辑说清楚然后给出可以直接抄的封装代码最后把踩过的坑一个个列出来。不管你是刚接触 MFC 的新手还是维护老项目的老手应该都能从里面找到能直接用的东西。需要提前说明的是本文所有代码基于 WinHTTP 实现这是 Windows 平台原生支持的 HTTP 客户端 API从 XP SP3 开始就有不需要额外依赖特别适合那些不能随便引入第三方库的老项目。如果你用的是比较新的 VS 版本也可以考虑 libcurl 或者 cpr 这类库但那是另一条路本文不展开。2. 方案选型WinHTTP 还是 WinINet2.1 两个 API 的本质区别很多人分不清 WinHTTP 和 WinINet觉得都是 Windows 自带的网络库用哪个不一样。实际上这两个东西定位完全不同选错了后面会很难受。WinINet 的全称是 Windows Internet它最初是为 IE 浏览器和 Windows 资源管理器这类交互式应用设计的。它内置了缓存管理、Cookie 管理、代理自动发现这些功能用起来确实方便一个 CInternetSession 就能搞定很多事情。但问题也在这儿——它的很多行为是跟系统设置绑定的比如代理配置直接读 IE 的设置缓存策略也是系统级的。对于一个后台服务或者工控软件来说这种自作主张的行为往往是灾难。WinHTTP 则是为服务端和后台应用设计的全称 Microsoft Windows HTTP Services。它不依赖 IE 的任何设置代理配置需要你自己指定没有隐式的缓存行为整个请求过程完全可控。更重要的是WinHTTP 对 HTTPS 的支持更加完整和稳定TLS 版本的控制也更清晰。我当时的判断标准很简单这个程序是给产线工人用的上位机需要长时间稳定运行不能因为系统里 IE 代理设置变了就出问题。所以直接选了 WinHTTP。2.2 为什么不用 libcurl可能有兄弟会问libcurl 不是更通用吗跨平台文档也全。这话没错但在这个项目里不适用。原因有三个第一编译环境太老。VS2010 的 C 编译器对 C99 的支持都不完整libcurl 的新版本源码编译会报一堆错。就算用预编译的静态库也要处理各种运行时库的匹配问题MT/MD、Debug/Release 组合起来很麻烦。第二项目要求零外部依赖。这个上位机是要打包发给客户的客户那边可能装在不同版本的 Windows 上多一个 DLL 就多一份风险。WinHTTP 是系统自带的winhttp.dll从 XP 开始就有完全不用担心部署问题。第三需求本身不复杂。就是发 GET 和 POST 请求处理 JSON 返回不需要 FTP、不需要断点续传、不需要 HTTP/2。用 WinHTTP 封装一层完全够用没必要为了通用性引入一个庞大的库。2.3 WinHTTP 的核心对象模型WinHTTP 的使用逻辑是典型的句柄式 API整个流程围绕几个核心句柄展开HINTERNET (Session)会话句柄通过WinHttpOpen创建代表一个应用级别的会话可以复用。HINTERNET (Connect)连接句柄通过WinHttpConnect创建指定目标主机和端口。HINTERNET (Request)请求句柄通过WinHttpOpenRequest创建指定方法、路径、协议等。发送与接收WinHttpSendRequest发送请求WinHttpReceiveResponse接收响应然后循环WinHttpReadData读取数据。这个模型跟 socket 编程的思路很像一层套一层用完要逐层关闭。理解了这个层级关系后面的代码就很好懂了。注意WinHTTP 的所有句柄都必须成对关闭Session 关的时候会自动关闭它下面所有的 Connect 和 Request但良好的习惯是逐层显式关闭避免资源泄漏。3. 核心细节HTTPS 请求的关键环节3.1 证书验证不能一关了之HTTPS 请求最核心的就是证书验证。很多网上的示例代码为了图省事直接调用WinHttpSetOption把SECURITY_FLAG_IGNORE_UNKNOWN_CA、SECURITY_FLAG_IGNORE_CERT_CN_INVALID这些标志全打开等于把 HTTPS 当 HTTP 用。这在生产环境里是绝对不行的等于把安全性完全丢掉了。正确的做法是让 WinHTTP 走默认的证书验证流程。WinHTTP 会自动验证服务器证书的有效性、颁发机构、域名匹配等。如果验证失败WinHttpSendRequest会返回错误错误码是ERROR_WINHTTP_SECURE_FAILURE。这时候你应该去检查服务器证书到底哪里有问题而不是简单地把验证关掉。当然有些内网环境用的是自签名证书这种情况确实需要特殊处理。我的建议是不要全局关闭验证而是把自签名证书的根证书导入到系统的受信任根证书存储区。这样 WinHTTP 就能正常验证通过安全性也有保障。如果实在做不到再考虑针对特定主机做例外处理但一定要在代码里写清楚注释说明为什么这么做。3.2 TLS 版本控制Windows 不同版本默认启用的 TLS 版本不一样。老版本的 Windows 7 默认可能只启用 TLS 1.0而现在很多服务器已经不支持 TLS 1.0 了只接受 TLS 1.2 或更高。这时候请求就会失败报ERROR_WINHTTP_SECURE_FAILURE或者类似的错误。解决办法是通过WinHttpSetOption设置WINHTTP_OPTION_SECURE_PROTOCOLS明确指定要使用的 TLS 版本。比如DWORD dwProtocols WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2; WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURE_PROTOCOLS, dwProtocols, sizeof(dwProtocols));需要注意的是这个标志是位掩码可以组合。但不要盲目地把所有版本都打开那样反而可能引入安全风险。根据服务器实际支持的版本来设置通常 TLS 1.2 是当前的主流选择。另外这个设置需要在发送请求之前调用而且要在 Request 句柄上设置不是 Session 句柄。3.3 超时设置避免界面卡死MFC 程序最怕的就是网络请求把界面卡死。WinHTTP 默认的超时时间比较长如果服务器不响应界面就会一直转圈。所以必须显式设置超时。WinHTTP 的超时通过WinHttpSetTimeouts设置有四个参数参数含义建议值nResolveTimeoutDNS 解析超时5000msnConnectTimeout连接超时10000msnSendTimeout发送超时30000msnReceiveTimeout接收超时30000ms这四个值要根据实际网络环境调整。内网环境可以设短一点公网环境要留足余量。我一般会把解析和连接设短一些因为这两个阶段如果失败通常重试也没用发送和接收设长一些因为数据量大或者服务器处理慢的时候需要时间。提示超时设置是在 Session 句柄上生效的创建 Session 之后立即设置即可。3.4 异步还是同步WinHTTP 支持同步和异步两种模式。同步模式下WinHttpSendRequest和WinHttpReceiveResponse会阻塞当前线程直到完成。异步模式则需要设置回调函数通过WinHttpSetStatusCallback处理各种事件。对于 MFC 程序我的建议是用同步模式但把请求放到工作线程里执行。这样代码逻辑简单又不会阻塞界面。异步模式的回调机制在 MFC 里用起来比较别扭容易出问题而且调试困难。具体做法是在主线程里创建一个工作线程把请求参数传进去工作线程里用同步方式发请求完成后通过PostMessage通知主线程更新界面。这是 MFC 里最经典的多线程模式稳定可靠。4. 完整封装可直接使用的 HTTPS 请求类4.1 类的整体设计基于上面的分析我封装了一个CHttpClient类提供两个核心方法Get和Post。类的设计目标是调用简单、错误处理完善、资源管理自动、支持超时和 TLS 配置。先看头文件// HttpClient.h #pragma once #include windows.h #include winhttp.h #include string #include vector #pragma comment(lib, winhttp.lib) class CHttpClient { public: CHttpClient(); ~CHttpClient(); // 设置超时毫秒 void SetTimeout(DWORD dwResolve, DWORD dwConnect, DWORD dwSend, DWORD dwReceive); // 设置 TLS 版本 void SetTLSVersion(DWORD dwProtocols); // GET 请求 bool Get(const std::wstring strUrl, std::string strResponse, std::wstring strError); // POST 请求 bool Post(const std::wstring strUrl, const std::string strBody, const std::wstring strContentType, std::string strResponse, std::wstring strError); private: bool DoRequest(const std::wstring strMethod, const std::wstring strUrl, const std::string strBody, const std::wstring strContentType, std::string strResponse, std::wstring strError); bool ParseUrl(const std::wstring strUrl, std::wstring strHost, INTERNET_PORT nPort, std::wstring strPath, bool bSecure); HINTERNET m_hSession; DWORD m_dwResolveTimeout; DWORD m_dwConnectTimeout; DWORD m_dwSendTimeout; DWORD m_dwReceiveTimeout; DWORD m_dwTLSProtocols; };这个类的接口设计尽量简洁调用者只需要关心 URL、请求体和返回内容底层的句柄管理、错误处理都封装在内部。4.2 URL 解析的实现WinHTTP 需要把 URL 拆成主机、端口、路径三部分所以第一步是解析 URL。这里用WinHttpCrackUrl这个 API 来做比自己写字符串处理靠谱得多。bool CHttpClient::ParseUrl(const std::wstring strUrl, std::wstring strHost, INTERNET_PORT nPort, std::wstring strPath, bool bSecure) { URL_COMPONENTS urlComp { 0 }; urlComp.dwStructSize sizeof(urlComp); urlComp.dwSchemeLength (DWORD)-1; urlComp.dwHostNameLength (DWORD)-1; urlComp.dwUrlPathLength (DWORD)-1; urlComp.dwExtraInfoLength (DWORD)-1; if (!WinHttpCrackUrl(strUrl.c_str(), (DWORD)strUrl.length(), 0, urlComp)) { return false; } strHost.assign(urlComp.lpszHostName, urlComp.dwHostNameLength); nPort urlComp.nPort; bSecure (urlComp.nScheme INTERNET_SCHEME_HTTPS); strPath.assign(urlComp.lpszUrlPath, urlComp.dwUrlPathLength); if (urlComp.lpszExtraInfo urlComp.dwExtraInfoLength 0) { strPath.append(urlComp.lpszExtraInfo, urlComp.dwExtraInfoLength); } return true; }这里有个细节要注意dwSchemeLength这些字段在调用前要设成(DWORD)-1这是 WinHTTP 的约定表示让 API 自己计算长度。如果设成 0API 会认为你不需要这个字段返回的指针就是空的。另外lpszExtraInfo是 URL 里问号后面的部分比如?id123这部分要拼接到路径里否则请求会丢失参数。4.3 请求发送的核心流程DoRequest是整个类的核心把 GET 和 POST 的公共逻辑都放在这里bool CHttpClient::DoRequest(const std::wstring strMethod, const std::wstring strUrl, const std::string strBody, const std::wstring strContentType, std::string strResponse, std::wstring strError) { strResponse.clear(); strError.clear(); std::wstring strHost, strPath; INTERNET_PORT nPort 0; bool bSecure false; if (!ParseUrl(strUrl, strHost, nPort, strPath, bSecure)) { strError LURL 解析失败; return false; } // 创建连接句柄 HINTERNET hConnect WinHttpConnect(m_hSession, strHost.c_str(), nPort, 0); if (!hConnect) { strError L连接失败错误码 std::to_wstring(GetLastError()); return false; } // 创建请求句柄 DWORD dwFlags bSecure ? WINHTTP_FLAG_SECURE : 0; HINTERNET hRequest WinHttpOpenRequest(hConnect, strMethod.c_str(), strPath.c_str(), NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, dwFlags); if (!hRequest) { strError L创建请求失败错误码 std::to_wstring(GetLastError()); WinHttpCloseHandle(hConnect); return false; } // 设置 TLS 版本 if (bSecure) { WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURE_PROTOCOLS, m_dwTLSProtocols, sizeof(m_dwTLSProtocols)); } // 设置 Content-Type if (!strContentType.empty()) { std::wstring strHeader LContent-Type: strContentType; WinHttpAddRequestHeaders(hRequest, strHeader.c_str(), (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); } // 发送请求 BOOL bResult FALSE; if (strBody.empty()) { bResult WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0); } else { bResult WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, (LPVOID)strBody.c_str(), (DWORD)strBody.length(), (DWORD)strBody.length(), 0); } if (!bResult) { DWORD dwErr GetLastError(); strError L发送请求失败错误码 std::to_wstring(dwErr); if (dwErr ERROR_WINHTTP_SECURE_FAILURE) { strError L证书验证失败请检查服务器证书; } WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); return false; } // 接收响应 if (!WinHttpReceiveResponse(hRequest, NULL)) { strError L接收响应失败错误码 std::to_wstring(GetLastError()); WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); return false; } // 检查状态码 DWORD dwStatusCode 0; DWORD dwSize sizeof(dwStatusCode); WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, dwStatusCode, dwSize, WINHTTP_NO_HEADER_INDEX); // 循环读取数据 DWORD dwBytesAvailable 0; do { dwBytesAvailable 0; if (!WinHttpQueryDataAvailable(hRequest, dwBytesAvailable)) { break; } if (dwBytesAvailable 0) { break; } std::vectorchar buffer(dwBytesAvailable); DWORD dwBytesRead 0; if (!WinHttpReadData(hRequest, buffer.data(), dwBytesAvailable, dwBytesRead)) { break; } strResponse.append(buffer.data(), dwBytesRead); } while (dwBytesAvailable 0); WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); if (dwStatusCode 200 || dwStatusCode 300) { strError LHTTP 状态码异常 std::to_wstring(dwStatusCode); return false; } return true; }这段代码有几个关键点需要说明。第一WinHttpSendRequest的最后一个参数是dwContext同步模式下传 0 就行。如果是 POST 请求请求体通过第五个参数传入第六个参数是请求体长度第七个参数是总长度。对于普通 POST这两个长度是一样的。第二读取响应数据必须用循环。WinHttpQueryDataAvailable返回当前可读的字节数可能一次读不完所以要循环直到返回 0。每次读取的缓冲区大小就是可读字节数这样效率最高。第三状态码检查放在最后。即使状态码不是 2xx响应体里也可能有错误信息所以先把数据读完再判断。这样调用者可以根据需要决定是否使用错误响应体里的内容。4.4 GET 和 POST 的封装有了DoRequestGET 和 POST 就很简单了bool CHttpClient::Get(const std::wstring strUrl, std::string strResponse, std::wstring strError) { return DoRequest(LGET, strUrl, , , strResponse, strError); } bool CHttpClient::Post(const std::wstring strUrl, const std::string strBody, const std::wstring strContentType, std::string strResponse, std::wstring strError) { return DoRequest(LPOST, strUrl, strBody, strContentType, strResponse, strError); }POST 请求的 Content-Type 通常是application/json或者application/x-www-form-urlencoded根据接口要求传入即可。4.5 构造函数和析构函数CHttpClient::CHttpClient() : m_hSession(NULL) , m_dwResolveTimeout(5000) , m_dwConnectTimeout(10000) , m_dwSendTimeout(30000) , m_dwReceiveTimeout(30000) , m_dwTLSProtocols(WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2) { m_hSession WinHttpOpen(LMFC-HttpClient/1.0, WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (m_hSession) { WinHttpSetTimeouts(m_hSession, m_dwResolveTimeout, m_dwConnectTimeout, m_dwSendTimeout, m_dwReceiveTimeout); } } CHttpClient::~CHttpClient() { if (m_hSession) { WinHttpCloseHandle(m_hSession); m_hSession NULL; } }这里WinHttpOpen的第二个参数用了WINHTTP_ACCESS_TYPE_NO_PROXY表示不使用代理。如果你的环境需要代理改成WINHTTP_ACCESS_TYPE_DEFAULT_PROXY或者手动指定代理服务器。注意WinHttpOpen的第一个参数是 User-Agent有些服务器会检查这个字段建议设置成有意义的值不要留空。5. 在 MFC 中集成工作线程与界面更新5.1 为什么必须用工作线程MFC 的主线程负责消息循环如果在主线程里调用WinHttpSendRequest整个界面就会卡住直到请求完成。如果服务器响应慢用户会以为程序死了。所以必须把网络请求放到工作线程里。MFC 提供了两种工作线程的创建方式AfxBeginThread和_beginthreadex。推荐用AfxBeginThread因为它跟 MFC 的运行时库配合得更好不容易出问题。5.2 线程参数的设计工作线程需要知道请求的 URL、方法、请求体还需要一个窗口句柄用来发送完成消息。我定义了一个结构体来传递这些参数struct HttpRequestParam { std::wstring strUrl; std::wstring strMethod; std::string strBody; std::wstring strContentType; HWND hNotifyWnd; UINT uMsg; WPARAM wParam; };hNotifyWnd是接收通知的窗口句柄uMsg是自定义消息wParam可以用来区分不同的请求。5.3 工作线程函数UINT HttpRequestThread(LPVOID pParam) { HttpRequestParam* pReq (HttpRequestParam*)pParam; if (!pReq) { return 1; } CHttpClient client; std::string strResponse; std::wstring strError; bool bSuccess false; if (pReq-strMethod LGET) { bSuccess client.Get(pReq-strUrl, strResponse, strError); } else { bSuccess client.Post(pReq-strUrl, pReq-strBody, pReq-strContentType, strResponse, strError); } // 把结果打包通过 PostMessage 发给主线程 HttpResponseResult* pResult new HttpResponseResult(); pResult-bSuccess bSuccess; pResult-strResponse strResponse; pResult-strError strError; if (pReq-hNotifyWnd) { ::PostMessage(pReq-hNotifyWnd, pReq-uMsg, pReq-wParam, (LPARAM)pResult); } delete pReq; return 0; }这里有个内存管理的细节pResult是在堆上分配的主线程收到消息后负责 delete。这是 MFC 里跨线程传递数据的标准做法因为PostMessage是异步的栈上的数据在消息处理时可能已经失效了。5.4 主线程的消息处理在主窗口类里定义消息处理函数// 消息映射 ON_MESSAGE(WM_HTTP_RESPONSE, CMainDlg::OnHttpResponse) // 处理函数 LRESULT CMainDlg::OnHttpResponse(WPARAM wParam, LPARAM lParam) { HttpResponseResult* pResult (HttpResponseResult*)lParam; if (!pResult) { return 0; } if (pResult-bSuccess) { // 处理成功响应 CString strText(pResult-strResponse.c_str()); // 更新界面... } else { // 处理错误 CString strError(pResult-strError.c_str()); AfxMessageBox(strError); } delete pResult; return 0; }5.5 发起请求的调用方式在需要发请求的地方void CMainDlg::OnBnClickedButtonRequest() { HttpRequestParam* pReq new HttpRequestParam(); pReq-strUrl Lhttps://api.example.com/data; pReq-strMethod LGET; pReq-hNotifyWnd GetSafeHwnd(); pReq-uMsg WM_HTTP_RESPONSE; pReq-wParam 1; AfxBeginThread(HttpRequestThread, pReq); }这样整个流程就串起来了点击按钮 - 创建工作线程 - 线程里发 HTTPS 请求 - 完成后 PostMessage - 主线程更新界面。界面全程不卡顿用户体验很好。6. 常见问题与排查技巧实录6.1 错误码速查表WinHTTP 的错误码比较多我整理了一份常见的错误码含义排查方向12001句柄不足检查是否有句柄泄漏12002超时检查网络和超时设置12007无法解析主机名检查 DNS 和 URL12017操作被取消检查是否主动调用了取消12029无法连接检查端口和防火墙12037证书日期无效检查系统时间12038证书颁发机构无效检查根证书是否受信任12045证书 CN 不匹配检查 URL 域名和证书域名12175安全通道错误检查 TLS 版本和证书6.2 证书问题的排查思路证书问题是最常见的也是最好排查的。首先用浏览器访问同一个 URL看浏览器有没有报证书错误。如果浏览器正常说明证书本身没问题问题出在 WinHTTP 的配置上。常见的原因有系统根证书缺失老版本的 Windows 可能没有最新的根证书需要手动更新。TLS 版本不匹配服务器只支持 TLS 1.2但客户端默认用 TLS 1.0。系统时间不对证书验证依赖系统时间时间偏差太大会导致验证失败。排查的时候可以先用WinHttpSetOption临时打开SECURITY_FLAG_IGNORE_UNKNOWN_CA如果这样能通就说明是证书验证的问题然后针对性地解决。6.3 中文乱码的处理WinHTTP 返回的数据是原始字节流编码取决于服务器。如果服务器返回 UTF-8 编码的中文直接当 ANSI 字符串处理就会乱码。正确的做法是先读取原始字节然后根据 Content-Type 里的 charset 或者响应体的 BOM 来判断编码最后转换成宽字符串。如果服务器返回的是 UTF-8可以用MultiByteToWideChar转换int nLen MultiByteToWideChar(CP_UTF8, 0, strResponse.c_str(), (int)strResponse.length(), NULL, 0); std::wstring strWide(nLen, 0); MultiByteToWideChar(CP_UTF8, 0, strResponse.c_str(), (int)strResponse.length(), strWide[0], nLen);如果服务器返回的是 GBK把CP_UTF8改成CP_ACP即可。实际项目中最好先解析响应头里的 Content-Type根据 charset 参数动态选择编码。6.4 内存泄漏的预防WinHTTP 的句柄如果忘记关闭会导致内存泄漏。我踩过一次坑在一个循环里反复创建 Session 但没有关闭跑了几万次之后程序直接崩溃。预防的办法是所有句柄都用 RAII 的方式管理或者确保每个WinHttpOpen、WinHttpConnect、WinHttpOpenRequest都有对应的WinHttpCloseHandle。在DoRequest里我用的是每个分支都关闭的方式虽然代码看起来有点啰嗦但不会漏。另外WinHttpReadData读取的数据要确保缓冲区大小足够不要用固定大小的缓冲区去读不确定长度的数据那样会溢出。6.5 代理环境下的特殊处理如果程序运行在需要代理的环境里WinHttpOpen的第二个参数要改成WINHTTP_ACCESS_TYPE_DEFAULT_PROXY或者用WinHttpSetOption设置WINHTTP_OPTION_PROXY手动指定代理服务器。需要注意的是WinHTTP 的代理设置跟 IE 是独立的不会自动读取 IE 的代理配置。如果用户的代理配置在 IE 里你需要用WinHttpGetIEProxyConfigForCurrentUser读取出来再设置给 WinHTTP。7. 几个容易被忽略的细节7.1 User-Agent 不能少有些服务器会检查 User-Agent如果为空或者太简单可能会拒绝请求。WinHttpOpen的第一个参数就是 User-Agent建议设置成有意义的值比如MyApp/1.0 (Windows)。不要用默认的空字符串也不要随便填一个浏览器的 UA那样反而可能引起服务器的怀疑。7.2 重定向要手动处理WinHTTP 默认不自动跟随重定向。如果服务器返回 301 或 302你需要自己读取 Location 头然后重新发起请求。这个逻辑在DoRequest里没有实现因为大多数 API 接口不会重定向。如果需要可以在状态码检查那里加一段处理。7.3 大文件下载要分块如果响应体很大比如下载文件一次性读到内存里可能会占用大量内存。这时候应该分块读取边读边写文件。WinHttpQueryDataAvailable和WinHttpReadData的循环本身就是分块的只需要把每次读到的数据写到文件而不是追加到字符串即可。7.4 连接复用WinHttpConnect创建的连接句柄是可以复用的。如果同一个主机要发多个请求可以只创建一次 Connect然后创建多个 Request。这样能减少 TCP 握手和 TLS 握手的开销提升性能。不过要注意Request 句柄用完必须关闭Connect 句柄在所有请求完成后关闭。7.5 调试技巧调试 HTTPS 请求的时候可以用 Fiddler 或者 Wireshark 抓包。但 HTTPS 是加密的直接抓包看不到内容。Fiddler 可以配置成中间人代理解密 HTTPS 流量但需要把 Fiddler 的根证书导入到系统受信任存储区。这个方法在开发阶段很有用但生产环境不要用。如果不想装抓包工具可以在代码里把请求和响应的原始数据打印到日志文件虽然看不到加密后的内容但能看到明文部分对排查问题也有帮助。8. 实际项目中的性能优化8.1 连接池的简单实现前面提到 Connect 句柄可以复用但在多线程环境下多个线程同时用一个 Connect 句柄会有问题。简单的做法是每个线程用自己的 Session 和 Connect虽然浪费一点资源但避免了线程安全问题。如果请求量很大可以考虑实现一个简单的连接池维护一组 Connect 句柄每个句柄绑定一个主机请求时从池里取用完放回。这个实现起来有点复杂一般项目用不上。8.2 超时时间的动态调整固定的超时时间不一定适合所有场景。比如内网请求可以设 3 秒公网请求可能要 30 秒。我的做法是在CHttpClient里提供SetTimeout方法调用者可以根据实际情况调整。默认值设成比较保守的 30 秒特殊场景再单独设置。8.3 请求合并如果短时间内有大量小请求可以考虑合并成一个请求。比如批量查询接口把多个 ID 拼成一个请求发过去减少网络往返次数。这个需要服务端接口支持不是所有场景都能用。9. 从 HTTP 迁移到 HTTPS 的注意事项9.1 端口变化HTTP 默认端口是 80HTTPS 默认端口是 443。如果代码里硬编码了端口迁移的时候要改。用WinHttpCrackUrl解析 URL 的话端口会自动从 URL 里提取不需要手动处理。9.2 URL 变化有些服务端在迁移到 HTTPS 的时候URL 路径也会变。比如从/api/getData变成/v2/api/getData。这个没有通用规律只能根据接口文档来调整。9.3 证书准备如果服务端用的是自签名证书需要把根证书导入到客户端的受信任存储区。这个操作可以在安装程序里做也可以提供一个小工具让用户手动导入。导入的时候要注意根证书必须导入到受信任的根证书颁发机构存储区而不是个人存储区。9.4 兼容性测试迁移完成后要在各种 Windows 版本上测试。Windows 7、Windows 10、Windows 11 的 TLS 支持情况不一样老版本可能需要打补丁才能支持 TLS 1.2。测试的时候要覆盖这些场景确保所有目标环境都能正常工作。10. 一些个人体会这套 WinHTTP 的封装我在三个项目里用过最长的已经稳定运行了两年多每天处理几万次请求没出过什么问题。总结下来最关键的就是三点证书验证不能关、超时必须设、请求必须放工作线程。证书验证这块我见过太多人为了图省事直接关掉结果被安全审计查出来返工重做。其实自签名证书的问题有正规的解决办法没必要走捷径。超时设置也是很多人不设结果服务器一挂客户端就卡死。设了超时之后至少能保证程序不会一直卡着用户体验好很多。工作线程这块MFC 的消息机制其实很适合做异步网络请求PostMessage加上堆上分配的结果对象模式很固定写熟了之后很快。最后再分享一个小技巧如果调试的时候不确定是客户端问题还是服务端问题可以用 PowerShell 的Invoke-WebRequest命令测试同一个 URL。如果 PowerShell 能通而你的程序不通那就是客户端代码的问题如果 PowerShell 也不通那就是网络或者服务端的问题。这个办法能快速定位问题范围省去很多瞎猜的时间。