
前阵子做一个内部小工具需要在MFC界面上调一个HTTP接口。同事第一反应是让我装libcurl我说MFC自带一个CHttpFile简单GET、POST完全够用了。这篇文章就聊聊我用CHttpFile时的完整思路和实际踩过的坑内容包括工程配置、GET/POST怎么写得稳、中文乱码怎么处理、超时和HTTPS怎么设置以及为什么不要直接在UI线程里发请求。适合刚用MFC做网络功能的朋友也适合想找一个轻量HTTP方案的老开发做参考。1. 为什么我在MFC项目里优先用CHttpFile而不是直接上libcurl1.1 CHttpFile到底是什么它和WinInet的关系很多从Win32裸写或者从Linux转过来的同学容易把MFC网络理解复杂。你可以把WinInet看成Windows提供的一套HTTP/FTP客户端API而CFtpFile是MFC对这套API的C封装。CHttpFile和CInternetSession配合起来省略了大量InternetOpen、InternetConnect、HttpOpenRequest这类句柄管理代码让开发者可以直接面向对象操作请求对象。最核心的几个类要分工清楚CInternetSession负责整个进程的网络会话可以把它理解成浏览器的一个标签页管理器CHttpConnection代表对某一台主机的连接里面记录了主机名、端口CHttpFile则对应一次具体的HTTP请求和响应GET、POST、读取响应都通过它完成。这个层级关系和HTTP协议本身是对应的建立会话 - 找到主机 - 发起请求。1.2 适用场景和不适用场景CHttpFile适合下面这些场景内网系统的API调用比如给MFC客户端加一个登录校验、拉取公告列表对接一些简单的文件上传接口POST一段JSON或表单数据下载小文件比如从服务器更新一个配置文件、图标包你不想额外引入第三方库也不想处理链接、编译依赖的时候。但它不适合做超大文件下载和复杂并发。WinInet的缓存、代理机制在特定环境下会带来意想不到的延迟而且MFC封装后的接口是同步阻塞的一旦遇到慢接口调用线程会一直挂住。如果要做断点续传、多线程下载、流式协议对接我建议还是选用专门的网络库别在CHttpFile上硬撑。1.3 工程里怎么把环境准备好在Visual Studio里新建MFC应用程序默认不会自动包含WinInet头文件。需要在stdafx.h或者使用到的文件里加上#include afxinet.h #pragma comment(lib, wininet.lib)这里有两个常见问题需要注意。一是如果你使用了Unicode字符集CString默认是宽字符版本和基于字节的HTTP报文内容有区别后面我会专门讲编码转换二是MFC应用如果静态链接MFC库WinInet库仍然需要显式链接不要只加头文件就跑。2. 先跑通GET理解CHttpFile的完整调用链2.1 五步调用法缺一不可一次最简单的GET请求逻辑上分五步创建CInternetSession对象用GetHttpConnection建立到目标主机的连接通过连接对象调用OpenRequest打开一个HTTP请求句柄调用SendRequest把请求真正发出去用ReadFile或ReadString循环读取响应。很多人第一步就不在意直接new了一个CHttpFile结果编译报错。CHttpFile自己不能创建必须从CHttpConnection的OpenRequest方法里拿。这个设计其实很合理一个连接对象可以多次创建CHttpFile相当于同一个域名下发起多个请求连接对象负责复用底层会话。2.2 一个能直接抄的GET函数下面这个函数是我项目里用的简化版本目标是传入完整URL返回字符串响应BOOL HttpGetString(const CString strUrl, CString strResponse) { if (strUrl.IsEmpty()) return FALSE; CString strServer, strObject; INTERNET_PORT nPort 80; DWORD dwServiceType 0; if (!AfxParseURL(strUrl, dwServiceType, strServer, strObject, nPort)) { // AfxParseURL解析失败大多数是因为URL里少了协议头 return FALSE; } CInternetSession session; CHttpConnection* pConnection NULL; CHttpFile* pFile NULL; try { pConnection session.GetHttpConnection(strServer, nPort); // INTERNET_FLAG_RELOAD表示强制从服务器拿数据不读本地缓存 pFile pConnection-OpenRequest(CHttpConnection::HTTP_VERB_GET, strObject, NULL, 1, NULL, NULL, INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE); pFile-SendRequest(); // 检查HTTP状态码200-299才认为是成功 DWORD dwStatusCode pFile-GetStatusCode(); if (dwStatusCode 200 || dwStatusCode 300) { strResponse.Format(_T(HTTP Error: %lu), dwStatusCode); return FALSE; } CStringA strRaw; char szBuf[8192] {0}; UINT nRead pFile-Read(szBuf, sizeof(szBuf) - 1); while (nRead 0) { szBuf[nRead] \0; strRaw szBuf; nRead pFile-Read(szBuf, sizeof(szBuf) - 1); } // 这里先假设服务器返回的是UTF-8后面会给出通用转换函数 // 如果你要的是窄字符CStringA可以直接返回strRaw strResponse UTF8ToUnicode(strRaw); return TRUE; } catch (CInternetException* pEx) { TCHAR szBuf[1024] {0}; pEx-GetErrorMessage(szBuf, 1024); strResponse szBuf; pEx-Delete(); return FALSE; } finally { if (pFile) pFile-Close(); if (pConnection) pConnection-Close(); session.Close(); } }注意finally在标准C里并不存在上面只是表示意图实际代码里你可以在函数退出前统一调用Close。MFC的Close是虚函数在析构函数里也会调用所以很多人不写也勉强能跑但遇到异常时资源释放顺序容易出问题。我习惯在分支里手动管理。2.3 状态码检查为什么不能省有些示例代码不检查状态码直接读数据结果服务器返回403或500读出来的内容是一段错误页面的HTML。这个行为有时候恰好能“正常显示”但会给后续解析造成极大困扰。我的习惯是拿到CHttpFile后立刻GetStatusCode()然后按2xx、3xx、4xx、5xx分别处理。3xx重定向时WinInet在默认情况下会自动跟随重定向但你最好还是确认一下最终的URL到底是不是你要的。2.4 用AfxParseURL还是自己拆URLAfxParseURL能帮我们把协议、主机名、路径、端口拆开但它对URL格式比较挑剔。比如你去掉http://它解析就会失败。还有一些同学会写正则去拆说实话没必要。URL承载的信息量有限用CString::Find配合Mid也完全够用。不过我自己在项目里发现一个更省心的方法如果接口地址是固定的干脆把主机名和路径分开配置连解析都省了。等后面接口多了再统一封装一个URL解析工具类。3. POST表单与JSON体把头信息写对请求就成功了一半3.1 传统表单提交的处理方式POST接口最常见的两种数据格式一种是application/x-www-form-urlencoded一种是application/json。表单方式最简单我直接给出关键代码CHttpConnection* pConnection session.GetHttpConnection(strServer, nPort); CHttpFile* pFile pConnection-OpenRequest( CHttpConnection::HTTP_VERB_POST, strObject, NULL, 1, NULL, NULL, INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE); CString strBody _T(usernameabcpassword123456); CString strHeaders _T(Content-Type: application/x-www-form-urlencoded\r\n); pFile-AddRequestHeaders(strHeaders); pFile-SendRequest(NULL, 0, (LPVOID)(LPCSTR)strBody, strBody.GetLength());这里有个容易被忽略的点SendRequest的最后一个参数是内容长度而不是字符串的GetLength()1千万不要把结尾的\0也发出去。另外(LPVOID)(LPCSTR)strBody在Unicode工程里会把宽字符串直接截断发送这是个天大坑。你要是用Unicode就必须先把CString转成UTF-8或ANSI的CStringA再发。3.2 JSON格式的POST请求JSON请求和表单唯一的区别就是Content-Type。加一个Content-Type: application/json; charsetutf-8服务器才会按JSON解析。这里还有个很重要的问题很多服务器对Content-Type的格式很敏感如果服务端严格要求只要application/json你多了charsetutf-8它也可能返回415。我自己的做法是默认不带charset除非对方接口文档明确说了要带。CString strBody _T({\name\:\test\,\age\:18}); CStringA strUtf8Body UnicodeToUTF8(strBody); // 后面有转换函数 CString strHeaders _T(Content-Type: application/json\r\n); pFile-AddRequestHeaders(strHeaders); pFile-SendRequest(NULL, 0, (LPVOID)strUtf8Body.GetBuffer(), strUtf8Body.GetLength());JSON字符串里如果包含中文不转UTF-8直接发送服务端十有八九会乱码。这个转换问题是MFC网络初学者最容易遇到的我下面单独开一节讲。3.3 关于Expect: 100-continue和Content-Length的坑如果你用抓包工具观察POST请求会发现有些请求头里自动带了Expect: 100-continue。这是HTTP/1.1的机制尤其当报文较大时客户端会先发头信息等服务器确认再发真正的Body。问题在于不少开发环境的内网服务器或者某些中间层并不支持这种行为它可能一直卡在等待状态。解决办法是在AddRequestHeaders里显式加上一行Expect:来清空这个头。注意不是写Expect: 1而是冒号后面留空告诉服务器我不想用这个机制。至于Content-LengthWinInet在调用SendRequest时一般会自动根据你传入的body长度生成但如果你用了AddRequestHeaders手工添加一个错误的Content-Length反而会让服务器等待过久最后超时。我自己就踩过这坑复制粘贴了一个静态Content-Length实际参数长度变了之后接口一直转圈。最后总结就是除了特殊情况不用手动加Content-Length。4. 乱码、半包、二进制数据处理绕不开的三件事4.1 为什么用固定缓冲区循环Read而不是等一下读完HTTP响应的数据不是一次性到达的尤其当服务器分块传输或者数据量比较大的时候你第一次ReadFile读到的很可能只是响应中的一个碎片。用一个大循环反复读取直到Read返回0才能保证拿到完整内容。代码里我把缓冲区设为8192字节是因为这个大小在网络传输中比较常见既能减少系统调用次数又不会让栈开销太大。注意每次读完后要手动给缓冲区末尾补一个\0否则把它拼到CStringA里会出现越界读取的野字符。4.2 UTF-8到Unicode的转换封装MFC的CString在实际工程里大多是宽字符CStringW而HTTP响应体普遍是UTF-8编码的字节流。直接从socket读出来的字节流按char处理显示到界面上就会变成乱码。我做了两个通用函数一个负责把UTF-8窄字符串转成宽字符串一个负责把宽字符串转成UTF-8发送给服务器。核心是通过Windows的MultiByteToWideChar和WideCharToMultiByte完成但要注意两个参数细节代码页用CP_UTF8并且转换前先调用一次获取目标长度再分配缓冲区。CString UTF8ToUnicode(const CStringA strUtf8) { if (strUtf8.IsEmpty()) return CString(); int nLen MultiByteToWideChar(CP_UTF8, 0, strUtf8, -1, NULL, 0); if (nLen 0) return CString(); CStringW strResult; WCHAR* pBuf strResult.GetBuffer(nLen); MultiByteToWideChar(CP_UTF8, 0, strUtf8, -1, pBuf, nLen); strResult.ReleaseBuffer(); return strResult; } CStringA UnicodeToUTF8(const CString strUnicode) { if (strUnicode.IsEmpty()) return CStringA(); int nLen WideCharToMultiByte(CP_UTF8, 0, strUnicode, -1, NULL, 0, NULL, NULL); if (nLen 0) return CStringA(); CStringA strResult; char* pBuf strResult.GetBuffer(nLen); WideCharToMultiByte(CP_UTF8, 0, strUnicode, -1, pBuf, nLen, NULL, NULL); strResult.ReleaseBuffer(); return strResult; }这个封装有几个好处转换过程和CString生命周期绑定不用手动释放内存空字符串直接返回避免转换函数在某些情况下返回-1的副作用同时兼容了Unicode工程和ANSI工程两种情况。如果项目里既有UTF-8又有GBK的接口你可以在函数里加一个参数控制目标代码页。4.3 二进制接口怎么安全存如果接口返回的是图片、文件、加密数据就别用CStringA去拼了因为字节流里可能含\0字符串操作会在第一个\0处截断。这时候建议用CByteArray或者标准库的std::vectorBYTE来收集数据。CByteArray arrData; char szBuf[4096] {0}; UINT nRead 0; while ((nRead pFile-Read(szBuf, sizeof(szBuf))) 0) { arrData.Append((BYTE*)szBuf, nRead); }CByteArray::Append会把数据原样保存。最后你可以通过GetData()拿到指针再根据业务需求保存成文件。需要注意的是这里没有补\0因为二进制数据本来就不需要字符串结尾符。5. 连不上、超时、HTTPS证书本机调试之外的网络难题5.1 超时设置session、connection、file三层都要管CHttpFile的默认超时时间在某些情况下很长接口如果服务器进程卡死客户端可能挂几十秒才报错。尤其是在UI线程里发请求体验非常让人崩溃。WinInet的超时设置可以作用在会话、连接、请求三个层级。我自己常用的做法是在拿到CHttpFile之后直接用它的句柄调用InternetSetOption设置发送和接收超时#include wininet.h // pFile是CHttpFile* DWORD dwTimeOut 5000; // 5秒 InternetSetOption(pFile-GetFileHandle(), INTERNET_OPTION_RECEIVE_TIMEOUT, dwTimeOut, sizeof(dwTimeOut)); InternetSetOption(pFile-GetFileHandle(), INTERNET_OPTION_SEND_TIMEOUT, dwTimeOut, sizeof(dwTimeOut));这里有个细节GetFileHandle()返回的是HINTERNET句柄不要和Windows普通的HANDLE混淆。有些教程会用SetOption方法去设置但MFC封装的SetOption主要针对会话级选项请求级的细粒度设置还是直接用原始API更靠谱。5.2 把CInternetException的错误码翻译成人话网络请求的异常远比本地文件操作复杂WinInet返回的错误码经常是ERROR_INTERNET_*。我的习惯是在catch块里先拿错误码再判断是哪种情况这样在日志里能看到真正的原因。catch (CInternetException* pEx) { DWORD dwError pEx-m_dwError; CString strMsg; switch (dwError) { case ERROR_INTERNET_CANNOT_CONNECT: strMsg _T(无法连接到服务器); break; case ERROR_INTERNET_TIMEOUT: strMsg _T(连接超时); break; case ERROR_INTERNET_INVALID_URL: strMsg _T(URL格式错误); break; default: pEx-GetErrorMessage(strMsg.GetBuffer(1024), 1024); strMsg.ReleaseBuffer(); break; } // 写日志... pEx-Delete(); }不要忘记调用pEx-Delete()而不是delete pExMFC异常对象建议通过Delete释放。这里犯迷糊的人不少。5.3 HTTPS证书问题CHttpFile是能支持HTTPS的但OpenRequest的时候必须加上INTERNET_FLAG_SECURE标志pFile pConnection-OpenRequest(CHttpConnection::HTTP_VERB_GET, strObject, NULL, 1, NULL, NULL, INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD);如果目标站点证书链不完整比如是自签名证书WinInet会弹出“安全证书错误”或者直接返回12045等错误码。调试阶段你可以临时忽略证书错误但不是通过什么隐藏开关而是设置INTERNET_OPTION_SECURITY_FLAGSDWORD dwFlags 0; DWORD dwBufLen sizeof(dwFlags); InternetQueryOption(pFile-GetFileHandle(), INTERNET_OPTION_SECURITY_FLAGS, dwFlags, dwBufLen); dwFlags | SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_CERT_CN_INVALID; InternetSetOption(pFile-GetFileHandle(), INTERNET_OPTION_SECURITY_FLAGS, dwFlags, sizeof(dwFlags));我明确建议这种忽略逻辑只允许在测试环境用而且最好用宏包起来。生产环境的证书校验必须要保持严格否则用户数据存在中间人攻击风险。6. 从能跑到好用真实项目里必须处理的三个隐患6.1 不要在UI线程里直接调用CHttpFileMFC的界面线程是最忙的人。按钮点击、鼠标移动、窗口重绘都在同一个线程里排队你突然让它去等一次HTTP响应只要网络慢100毫秒整界面卡100毫秒如果服务器5秒没响应这个窗口直接就“未响应”了。有些人会在OnPaint里检查网络状态这是更严重的错误。OnPaint的调用频率比你想的高得多一次意外阻塞就可能把界面卡死。我的建议是把网络请求封装成任务放到AfxBeginThread的线程函数里执行完成后再通过PostMessage通知主线程更新界面。CHttpFile本身没有跨线程的限制但注意同一个队象不要同时在多个线程操作。6.2 代理和缓存CHttpFile的隐性行为WinInet默认会读取系统的IE代理设置。在办公网环境里这台机器如果配了代理你的HTTP请求会自动走代理而在没有代理的局域网里这可能导致连接超时。你可能会觉得奇怪为什么网页能打开程序却连不上内网服务器多半就是代理设置捣的鬼。解决办法是在CInternetSession创建时指定INTERNET_FLAG_NO_AUTO_PROXY或者自己填入代理参数。同理GET请求默认可能走WinInet缓存导致你明明改了服务器内容客户机还是拿到旧数据。所以OpenRequest时通常要加INTERNET_FLAG_RELOAD和INTERNET_FLAG_NO_CACHE_WRITE前者强制刷新后者避免把响应写入缓存。6.3 连接对象析构与重试策略网络不可靠哪怕代码写得再对也有偶发性失败。我习惯在网络请求失败后做一次重试而不是直接把错误弹窗丢给用户。重试前需要注意先关闭当前CHttpFile和CHttpConnection对象再重新走一遍完整流程不要复用已经处于错误状态的连接对象。CHttpConnection和CHttpFile析构的时候会向服务器发送连接关闭的清理动作。如果你没有手动Close析构顺序不对可能造成句柄泄漏。一个稳妥的写法是借助局部对象的特性在try块外定义指针在finally里统一清理或者干脆封装成单独的请求类在析构函数里锁好清理逻辑。最后再分享一个关于接口调试的实际习惯我现在的习惯是所有HTTP请求方法都统一封装成HttpGet、HttpPostForm、HttpPostJson返回值不光有接口字节内容还带回一个结构体里面放状态码、错误码、耗时。这样出了问题截图日志就能知道是哪一步失败而不是让业务代码到处拼CString去判断。如果你打算在MFC项目里长期使用CHttpFile强烈建议先花半天把超时、编码、代理、证书这几个基础问题做成工具函数。后面接什么接口都是几分钟的事。这比一上来就塞一个libcurl再折腾编译依赖要轻量得多维护起来也清爽。