1. 一次尴尬的断网事故:为什么MFC网络程序总在关键时刻"装死"
先讲一个我自己踩过的坑。几年前我在维护一个基于MFC的工业上位机程序,主要工作是和现场的PLC通过HTTP接口交换数据。某个周五下午,现场操作工一个不小心把网线踢松了,上位机界面立刻卡死,鼠标转圈,标题栏出现"未响应",最后只能强制结束进程。重启之后数据缓存丢了,一个班次的报表全没了,被生产主管念叨了一下午。事后我翻代码,发现问题是典型的"裸奔式"网络调用——代码里到处是AfxParseURL / CHttpConnection::OpenRequest / CHttpFile::ReadString 这种API,但几乎没有任何异常防护。一旦底层WinINet出错,MFC默认的异常处理机制就会把异常抛到消息循环之外,程序直接崩溃或者挂起。
这个经历让我认识到一个问题:接触过MFC的人很多,写过MFC网络代码的人也不少,但真正把 CInternetException 用明白的人,真的不多。 这不是说大家不努力,而是这个类本身的信息比较零散——MSDN资料很全但偏查询式,中文社区里的讨论要么太浅(贴一段try-catch就算完事),要么太旧(还是VC6时代的写法)。所以我想借这篇博客,把自己从各种项目里攒下的经验和踩过的坑系统整理一遍,按"错误从哪里来"、"代码里怎么接住"、"接住之后怎么转成用户能看懂的信息"这条主线,把CInternetException讲透。
这篇文章适合谁看?如果你正在用MFC做上位机通信、局域网工具、教学管理系统这类需要联网的小体量应用,而且被"一断网就崩"折磨过,那这篇内容就是冲你来的。纯做Web后端的、做Java/C#桌面开发的朋友,技术细节不一定通用,但"网络层异常必须显式处理"这个思路同样适用。我会尽量把每个知识点都讲到"可操作"的粒度,不整虚的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CInternetException到底是什么:和WININET错误码的对应关系
CInternetException 是 MFC 里专门为 WinINet API 设计的一套网络异常包装。很多新人的误区是把"异常"等同于"错误码",其实两者有关联但不完全是同一个东西。错误码是系统给出的一个数字,异常是代码流程层面的一个跳转。MFC 的设计思路是:在 WinINet 封装层内部(CInternetSession、CHttpConnection、CHttpFile 这些类的方法里),一旦底层 API 调用失败,就检查 GetLastError 并构建一个 CException 派生对象抛出来。你如果不接,程序就按默认方式处理,大概率是弹个错误对话框然后中止操作;如果你接住了,就可以描述具体的失败原因、做重试或者写日志。
2.1 继承体系和构造函数:两个DWORD里藏着关键信息
CInternetException 的继承链很简单:CException -> CInternetException。它没有自己额外的复杂成员结构,核心数据就是两个 DWORD:m_dwError 和 m_dwContext。这两个字段在构造时就被填好,之后没有专门的setter,所以捕获异常之后一定要在 catch 块内第一时间读取,别想着存个指针之后再用。
构造函数的形状是:
cpp复制CInternetException(
DWORD dwError,
DWORD dwContext = 0
);
- dwError:表示具体错误,取值分两大来源。一部分是 WinINet 的错误码(以
ERROR_INTERNET_开头,数值范围在 12000 到 12156 之间),另一部分是 MFC 自己定义的映射错误(如INTERNET_ERROR_GET_CONNECTED_STATE这类)。 - dwContext:表示错误发生的操作上下文。如果你在构造 CInternetSession 时传入了上下文 ID(通常是 AppID 或窗口句柄),这个值就是它;如果没传,默认0。它在多线程或多会话并存时用来区分是哪个操作出了问题,用处不小,但很多书上一句话带过,导致很少有人真正用起来。
我在实际项目里一般是这么设计:初始化 CInternetSession 时传入一个窗口句柄或预定义的场景ID,这样 catch 到异常之后第一件事先看 m_dwContext,就能立刻知道是"设备注册请求"挂了还是"心跳上报请求"挂了,不用再根据调用栈猜。
2.2 MFC错误码vs WinINet错误码:两条来源怎么分辨
你可能会问:既然 dwError 有两大类来源,我怎么知道当前拿到的到底是哪一类?有个土办法,先看数值范围。WinINet 错误码在 12000~12156 之间,MFC 自定义错误码则不在这个区间。当然你也可以#include <afxinet.h>,里面其实已经帮我们声明了一套常量,比如:
| 类别 | 常见示例 | 大致含义 |
|---|---|---|
| WinINet原生错误 | ERROR_INTERNET_TIMEOUT(12002) |
请求超时 |
| WinINet原生错误 | ERROR_INTERNET_NAME_NOT_RESOLVED(12007) |
DNS无法解析主机名 |
| WinINet原生错误 | ERROR_INTERNET_CANNOT_CONNECT(12029) |
无法连接到服务器 |
| WinINet原生错误 | ERROR_INTERNET_CONNECTION_ABORTED(12030) |
连接被中止 |
| MFC映射错误 | INTERNET_ERROR_GET_CONNECTED_STATE |
获取连接状态失败 |
| MFC映射错误 | INTERNET_ERROR_OPERATION_CANCELLED |
操作被主动取消 |
这里有一个容易踩坑的地方:MFC的封装在个别场景下并不会如实透传 WinINet 错误码,而是包装成自己的错误码。 比如你调用 CInternetSession::OpenURL 成功但后续读取超时,有的版本得到的 dwError 是一个 MFC 层的映射值,而 WinINet 原始错误被藏在更深的地方。遇到这种情况,单纯比较 e.m_dwError == ERROR_INTERNET_TIMEOUT 就失效了。正确的做法是:先用 e.GetErrorMessage 拿描述字符串辅助定位,再结合代码路径判断是哪一层的错误。这个"对不上号"的现象,在旧版 MFC(VC2010之前)里更明显,新版本好一些,但仍建议养成"看数值+看消息文本+看调用栈"三重确认的习惯。
3. 捕获与清理:CInternetException的正确接法
代码里最典型且错误的写法是这样的:
cpp复制try
{
CHttpFile* pFile = pConnection->OpenRequest(...);
pFile->SendRequest();
pFile->Read(buffer, sizeof(buffer));
}
catch (CInternetException* e)
{
// 有时候写个MessageBox,有时候干脆注释掉,甚至直接空catch
e->Delete();
}
单看 catch 块,e->Delete() 是有的,框架不崩。但错误信息呢?被吃掉了。用户只看到"操作失败"四个大字,日志系统里啥都没有。这种"接了等于没接"的防护,在排查问题时毫无帮助。正确做法至少要分三步走:读取错误字段、记录日志、释放异常对象。顺序不能乱,因为你一旦 delete 了异常对象,m_dwError 就变成悬空指针内存,再读就是未定义行为。
3.1 为什么必须调用 Delete() 而不是 delete
老手都知道 CException 派生对象要用 Delete() 释放,而不是直接 delete,但刚接触 MFC 的人完全没概念。原因是 MFC 的 CException 体系支持两种构造方式:栈上构造和堆上构造。栈上构造时对象生命周期随作用域自然结束,你调 Delete() 里面也有保护逻辑不会重复销毁;堆上构造时 Delete() 才会真正触底删除。CInternetException 绝大多数情况下都是从框架内部 new 出来然后 throw 的,所以 catch 到之后不调 Delete(),百分之百内存泄漏。写 delete e 呢?在少数"栈上构造后手动 throw 出来"的场景里会直接导致未定义行为。不要心存侥幸,统一用 e->Delete(),这是 MFC 官方钦定的规范用法。
3.2 我的通用捕获模板:日志先行,提示靠后
这几年我磨出来的模板大致长这样:
cpp复制catch (CInternetException* e)
{
DWORD dwErr = e->m_dwError;
DWORD dwCtx = e->m_dwContext;
TCHAR szErrMsg[512] = { 0 };
e->GetErrorMessage(szErrMsg, 512);
// 写日志
WriteLog(_T("[HTTP] CInternetException, Context=%lu, Error=%lu, Msg=%s"),
dwCtx, dwErr, szErrMsg);
// 用户可读的提示(只给友好信息,不刷屏)
AfxMessageBox(FormatFriendlyError(dwErr));
// 释放
e->Delete();
return FALSE; // 或者根据业务决定是否重试
}
这个模板的要点有三个:第一,GetErrorMessage 必须在 Delete 之前调用,这是硬性顺序。第二,建议自己封装 FormatFriendlyError,不要直接把系统返回的英文错误字符串抛给用户,尤其面对现场操作工时,友好的中文提示能少很多"报错给IT看"的工作。第三,写日志最好和用户提示分开处理。日志要详细,最好把上下文ID、错误码、错误文本、当前时间、甚至调用函数名都记进去;用户提示要克制,不要弹一串error code,没有意义。
3.3 一次性Try-Catch块的局限
这里要特别提醒:try-catch 覆盖的代码范围要足够大,但也不能太大。 我的经验是,一次网络请求(建立连接、发送请求、读取响应、关闭)应该尽量放在同一个 try 块内。为什么?因为 CInternetException 可能在任何一步抛出,比如 OpenRequest 阶段可能因为代理设置错误失败,SendRequest 阶段可能因为服务器响应慢超时,Read 阶段可能因为对端断开连接失败。如果每个函数都写一个小 try-catch,异常被层层吞掉,最后根本定位不了到底哪一环出了问题。反过来,如果把整个程序生命周期里所有的网络操作都包在一个 try 里,一旦异常发生,你不知道是哪个操作抛的,m_dwError 的值也有了歧义。中庸之道就是"一整个业务动作一个 try"。
4. 错误码转译成"人话":现场用户真正需要看到什么
一个让我印象特别深刻的场景是:设备测试现场的网络不稳定,操作工隔一会儿就弹出一个英文报错对话框,内容是"Error 12007: The server name could not be resolved"。操作工哪看得懂这个,直接抄在纸条上拍给我,我还得反问他"当时DNS是不是掉了"。其实这个问题完全可以靠错误码翻译解决。
4.1 常见WinINet错误码的用户友好文案
我整理了一张自己项目里在用的映射表,可以直接抄作业。注意关键点:面向用户的文案不是把英文翻译成中文就完事,而是要告诉用户"现在发生了什么、下一步大概率该怎么办"。
| 错误码 | 技术含义 | 用户友好文案 |
|---|---|---|
| 12002 | 请求超时 | "连接服务器超时,请检查网络和服务器状态,再试一次。" |
| 12007 | 主机名无法解析 | "无法解析服务器地址,请检查服务器IP或域名配置。" |
| 12029 | 无法建立连接 | "无法连接到目标服务器,请确认服务器是否启动、防火墙是否放行。" |
| 12030 | 连接被中止 | "与服务器的连接被中断,可能是网络不稳定或服务端主动断开。" |
| 12031 | 连接被重置 | "连接被重置,请稍后重试,若频繁出现请联系管理员检查网络。" |
| 12152 | 服务器返回无效响应 | "服务器返回了异常数据,请联系IT检查服务端接口。" |
这个表不是让你直接在代码里硬编码 if-else,我是用 std::unordered_map<DWORD, CString> 做了一次映射,项目里其他模块也能复用。映射之外再留一个兜底分支,比如"网络错误(错误码 %lu),请稍后重试"。注意兜底文案里保留原始错误码,方便远程指导。
4.2 GetErrorMessage 与 GetLastError 的区别
有人问:CInternetException 里已经拿到了错误信息,那我还要不要调 GetLastError?我的建议:不要依赖 GetLastError 来做用户提示和日志记录的唯一来源,尤其是在网络错误场景。 GetLastError 返回的是线程局部存储的错误码,它是"最近一次系统调用"的错误记录。可是在 CInternetException 抛出之前,MFC 框架内部可能已经调用过多个 WinINet 函数,GetLastError 被覆盖成了最后一个失败调用的值,和你真正关心的那个错误已经不是一回事。错误码的可信度排序是:CInternetException::m_dwError > GetErrorMessage() 返回的文本 > GetLastError()。但反过来,m_dwError 缺失时,GetLastError 还是可以当辅助参考的。比如有的 MFC 方法内部捕获异常后不重新抛出,而是返回 FALSE,你只能靠 GetLastError 去猜。
4.3 跳转到带上下文的新流程:错误恢复与重试机制
光翻译错误码还不够,实际项目中还得考虑"出错了怎么办"。常见策略有三种:直接返回、自动重试、提示用户后重试。我一般这样设计:对于瞬时性错误(超时、连接中止),自动重试2~3次,每次间隔500ms;对于确定性错误(主机名无法解析、无法连接且已知服务器地址配置错误),直接提示用户检查配置,不重试。自动重试要注意一个坑:如果用同一个 CInternetSession 重试同一个请求,有些状态下连接并没有彻底断开,重试时可能遇到半开连接的脏数据。 保险做法是每次重试新建 session 或至少新建 connection,旧连接老老实实 Close。这个经验是在一次"重试必现数据错乱"的问题里总结出来的,后面凡是做重试,我都强调"新请求新连接"原则。
5. 三种常见触发场景的完整排查链路
这一节我想把实际调试时看到最多的三类报错场景串起来,从问题表象到根因定位,给出完整的排查思路。代码不复杂,但排查链路的价值在于"你怎么能从一堆线索里快速锁定原因"。
5.1 场景一:断网瞬间,程序直接崩溃
表象:拔掉网线,程序立即弹错,甚至直接退出,事件查看器里记录着"0xc0000005 访问冲突"之类的问题。
排查链路:
- 查崩溃栈,看是在 CHttpFile::ReadString 里崩的,还是在 UI 线程的消息处理函数里崩的。
- 如果栈底是网络相关 API,说明异常漏了——很可能网络代码从头到尾没有 try-catch。
- 如果栈底在某个自定义回调里,且网络上没包 try,那就是 WinINet 异步回调里抛出的 CInternetException 没人接,直达 CRT 的默认异常处理,直接终止进程。
- 解决方案就是给整个请求流程套上 3.2 节的模板。这里我要强调一个细节:如果用了 EnableStatusCallback 设了状态回调,回调函数内部也要包 try-catch。 很多崩溃发生在回调里,就是因为开发者在回调里做了网络读取,但以为主流程里的 try 能兜住一切,事实上不同线程的异常栈是隔离的。
5.2 场景二:HTTP 请求返回 404,但 MFC 没抛异常
表象:服务端接口路径写错了,返回 404,但代码里 SendRequest 和 ReadString 都没报错,程序默默读到了 404 页面内容,后续 JSON 解析失败,报一个莫名其妙的数据格式错误。
排查链路:
- 抓包(或用 Fiddler/Charles)确认请求确实返回 404。
- 对照 MFC 文档发现:CInternetException 是"传输层错误",HTTP 404 属于"业务层响应",WinINet 默认不会因为这个甩异常。
- 正确做法是主动检查
CHttpFile::GetStatusCode()或GetFileURL()后的状态码,判断是否 2xx;不是 2xx 就自己手动抛 CInternetException 或自定义业务异常。 - 我当时写的代码长这样:
cpp复制DWORD dwStatusCode = pFile->GetStatusCode();
if (dwStatusCode >= 400)
{
CString strMsg;
strMsg.Format(_T("HTTP request failed, status code = %lu"), dwStatusCode);
throw new CInternetException(ERROR_INTERNET_INVALID_CAUSE, dwStatusCode);
}
这个自定义抛异常的方式,既保留了 MFC 异常的捕获方式(上层 catch 不用改),又把业务错误包装进了同一套错误处理体系,是个很实用的技巧。注意这里的 dwContext 我传了状态码,方便日志输出时直接看到 HTTP 状态,虽然不符合"上下文ID"的原意,但在小项目里是合理的变通。
5.3 场景三:FTP 下载文件断点续传时,进度到一半就停止
表象:用 CFtpConnection 下载大文件,网速一波动进度条就卡住,然后整个对话框卡死,过一会才有反应。
排查链路:
- 卡死大概率是因为网络调用跑在了 UI 线程,且阻塞读线程。
- 查看日志,有没有 CInternetException? m_dwError 是 12002(超时)还是 12030(连接中止)。
- 如果是超时,说明 WinINet 默认超时时间太长(通常是30秒甚至更多),你需要主动设置 CInternetSession 的超时时间:
SetOption(INTERNET_OPTION_CONNECT_TIMEOUT, ...)、SetOption(INTERNET_OPTION_RECEIVE_TIMEOUT, ...)。 - 如果是连接中止,是服务端主动断开,这时候就算重试也很难续上,需要重新登录、重新定位断点。
- 从架构上,最稳的方案是把下载动作放到工作线程,每下载一块数据就发消息更新 UI,这样就算网络卡住也最多是后台线程卡住,UI 不会"假死"。我在写 MFC 上位机时基本都是这条规矩:凡是可能阻塞超过100ms的IO操作,一律别放UI线程。
这三个场景的共同点是:问题表象千奇百怪,根因链条其实都指向"异常没接住"或"错误码没查透"。 排查的时候先稳住心态,从异常类型和错误码出发,一层层往底层剥,基本都能找到病灶。
6. 工具函数封装:日志、格式化、重试策略一次搞定
老写重复代码没意思,我把项目里常用的 CInternetException 处理逻辑封装成了一个工具头文件,分享出来供参考。这不是万能库,但结构上足够清晰,适合中小型 MFC 项目直接用或者按需改造。
6.1 LogInternetException:一个通用的异常记录函数
cpp复制// InternetExceptionUtils.h
#pragma once
#include <afxinet.h>
#include <spdlog/spdlog.h> // 如果有用 spdlog 的话,没有就直接用 OutputDebugString
void LogInternetException(CInternetException* e, const TCHAR* strFuncName)
{
if (!e) return;
DWORD dwErr = e->m_dwError;
DWORD dwCtx = e->m_dwContext;
TCHAR szBuf[1024] = { 0 };
if (e->GetErrorMessage(szBuf, 1024))
{
// 这里按你自己的日志组件调整;老项目用 OutputDebugString 也行
spdlog::error("[{0}] CInternetException context={1} error={2} msg={3}",
CT2A(strFuncName), dwCtx, dwErr, CT2A(szBuf));
}
else
{
spdlog::error("[{0}] CInternetException context={1} error={2} (unknown msg)",
CT2A(strFuncName), dwCtx, dwErr);
}
}
这个函数解决的一个痛点是:CInternetException 的 GetErrorMessage 在错误码异常时可能返回 FALSE,但你仍然需要把错误码打出来。所以先调 GetErrorMessage,失败也要把 m_dwError 记下来。日志里 context 和 error 一定要挨着,我见过不少人只打 error 不打 context,结果多线程场景排查起来一脸懵。
6.2 FormatFriendlyError:给 UI 层用的错误文案
写 UI 层提示之前,我一直建议先把错误码格式化成一个友好字符串,而不是让每个按钮的点击事件里都去写一遍 MessageBox 的拼装逻辑。格式化函数我通常是放在同一个工具文件里:
cpp复制CString FormatFriendlyError(DWORD dwError)
{
switch (dwError)
{
case ERROR_INTERNET_TIMEOUT:
return _T("连接超时,请检查网络后再试。");
case ERROR_INTERNET_NAME_NOT_RESOLVED:
return _T("服务器地址无法解析,请检查DNS或服务器配置。");
case ERROR_INTERNET_CANNOT_CONNECT:
return _T("无法连接到服务器,请确认服务端已启动且防火墙未拦截。");
case ERROR_INTERNET_CONNECTION_ABORTED:
return _T("与服务器的连接中断,请重试。");
default:
{
CString str;
str.Format(_T("网络异常,错误码:%lu"), dwError);
return str;
}
}
}
调用位置一定是在 catch 块里,而且只调用一次,别在循环里格式化。很多 UI 卡顿,不是因为网络没处理好,反而是异常弹出逻辑太粗暴,连续弹了几十个对话框把消息队列撑爆了。所以我还有一个硬性建议:错误弹窗一定要做去重/节流。比如 3 秒内同类错误只弹一次,后续的错误只进日志不打扰用户。
6.3 重试函数模板:带退避策略的发送请求
我最终沉淀出来的网络请求模板长这样,同时兼顾了异常捕获、日志、友好提示和重试:
cpp复制bool SendHttpRequestWithRetry(const CString& strUrl,
int nMaxRetryCount = 2)
{
for (int nAttempt = 0; nAttempt <= nMaxRetryCount; ++nAttempt)
{
try
{
CInternetSession session;
CHttpConnection* pConn = session.GetHttpConnection(strUrl);
// 此处省略 OpenRequest / SendRequest / Read 的具体细节
// ...
return true;
}
catch (CInternetException* e)
{
LogInternetException(e, _T(__FUNCTION__));
DWORD dwErr = e->m_dwError;
e->Delete();
// 如果属于瞬时性错误,且还有重试次数,退避后继续
if (IsRetryableError(dwErr) && nAttempt < nMaxRetryCount)
{
Sleep(500 * (nAttempt + 1)); // 简单退避:500ms, 1s
continue;
}
AfxMessageBox(FormatFriendlyError(dwErr));
return false;
}
}
return false;
}
其中 IsRetryableError 只判断 12002、12030、12031 这类瞬时错误。这里再重申一遍 5.1 节的原则:重试时如果复用了 session,请先 Close 再重来,或者干脆让每次循环都在局部作用域内新建 session。为什么?因为一个已经被异常中断的 session,内部状态不可信,虽然大部分情况下继续用也能工作,但"大部分情况"正是软件工程最危险的词。
7. 多线程与异步场景下的异常处理:一个容易被忽视的大坑
MFC 网络编程还有一个绕不开的话题就是多线程。很多 MFC 程序不会特意写多线程网络,但 UI 线程一旦调用阻塞式接口,程序就会卡死,于是大家自然会想到开工作线程。线程一开,异常处理就变复杂了。
7.1 工作线程里 catch 住异常之后,怎么通知主线程
C++ 异常是不能跨线程传递的(准确说,C++ 标准里异常是线程本地的),你在工作线程里 catch 到 CInternetException,不能直接 throw 到主线程去。常见的做法是:在工作线程 catch 块里先记录完整信息,再通过 PostMessage 把错误码等关键数据发到主线程。这里的"关键数据"用自定义消息结构,不要用全局变量裸传,原因很简单:消息队列是异步的,你用全局变量存错误码,主线程收到消息的时候,那个全局变量可能已经被下一次请求覆盖了。
一个我踩过很深的坑:工作线程里 catch 异常后,直接更新了一个全局 CString 保存错误描述,然后 PostMessage 通知主线程弹框。结果主线程弹框读取这个 CString 时,恰逢另一个工作线程改了它,界面显示乱码甚至崩溃。后来统一改成了带副本的 PostMessage,用 WM_COPYDATA 或者自定义结构体 + 拷贝,再也没出过问题。
7.2 EnableStatusCallback 回调里的异常
EnableStatusCallback 是把 MFC 网络状态回调挂在 CInternetSession 上用的,它会在网络事件发生时触发,比如 INTERNET_STATUS_RESPONSE_RECEIVED、INTERNET_STATUS_REQUEST_COMPLETE 等。回调函数和主调函数往往不在同一线程上下文里执行,尤其涉及异步模式时。回调里如果发生异常,外层 try 可能救不了你——因为异常不是从你写 try 的那个栈帧抛上来的。我的建议是:回调内部自己再包一层 try-catch,宁可多包不多漏。
7.3 线程退出时的清理顺序
最后一个经验:线程退出前,一定要确保 CInternetSession 对象先销毁,或者说先确保所有的网络操作都退出了,再销毁线程。 如果线程还在读网络数据,你已经把 CInternetSession 指针 delete 了,那接下来的 CInternetException 就变成了悬空指针上的调用,这是崩溃重灾区。正确做法是:先给工作线程发退出通知,等线程函数里所有 try-catch 块执行完、网络对象 scope 结束,再安全 join。
8. 与 CException 家族其他网络相关异常的辨别技巧
MFC 里不是只有 CInternetException 一种异常和网络有关。很多人会混淆 CFileException 和 CInternetException,因为 CHttpFile 继承自 CStdioFile,而 CStdioFile 继承自 CFile,所以文件读取相关的异常也经常在HTTP读取时冒出来。
8.1 CInternetException 和 CFileException 的边界
判断准则是:凡是 WinINet 传输层直接抛出的,是 CInternetException;凡是到了 CFile 层读写时出的问题,是 CFileException。 举个例子,CHttpFile 继承自 CStdioFile,因此你可以用 ReadString 来读 HTTP 响应体。读的过程中如果网络断开、底层 socket 出错,WinINet 会甩 CInternetException;但如果你读了太多的数据导致本地缓冲区错误,或者文件读写模式不对,可能是 CFileException。所以在写捕获逻辑时,我经常是双重捕获:
cpp复制catch (CInternetException* e)
{
// 处理网络异常
}
catch (CFileException* e)
{
// 处理文件/缓冲异常
}
注意顺序:catch 块的匹配顺序是从上到下,如果你先 catch 了 CException*,会拦截掉所有派生异常。 所以 CInternetException 和 CFileException 都必须写在 CException 之前。如果你用 MFC 的标准宏,也有 TRY/CATCH/AND_CATCH/END_CATCH 的写法,但那个宏语法在我看来比标准 C++ 异常更难读,尤其在处理嵌套异常时容易迷失,我平时宁可用标准 try-catch,加上一点判断来处理 MFC 异常类型的特殊性(比如需要调用 Delete)。
8.2 什么时候该主动抛 CInternetException
不是只有 MFC 底层会抛 CInternetException,我们自己也可以主动抛。除了前面 5.2 节提到的"HTTP 非2xx状态码手动抛"之外,还有一个典型场景:你在读取响应之后,想统一把"数据格式不符合预期"当作网络异常来对待。 比如 JSON 解析失败、XML 节点缺失,如果业务方不关心具体业务错误,只想知道这单请求成功与否,那你直接抛 CInternetException 可以省很多分支代码。但我的建议是别这么干,业务错误和网络错误混在一起会让日志系统失真,还是建议定义独立的 CException 派生类或者用返回码表达。主动抛 CInternetException 的场景,我只推荐面向"网络链路本身"的异常,比如 404、500、503,这些确实属于网络请求层面的问题。
9. 我的经验补充:关于MFC网络程序的长期维护建议
最后聊点比较"软"的经验,但可能比前面的代码细节更能决定项目后期活得舒不舒服。
9.1 日志里一定要带时间戳和网络标识
诊断网络异常时,最痛苦的不是不知道错误码,而是不知道"这个错误是哪个时刻、哪一次请求、哪一条链路报出来的"。所以我项目里给网络层打日志时,强制带上三样东西:精确到毫秒的本地时间、请求目标URL的域名或IP、以及本次请求的事务ID(用一个自增序号就够)。这样查日志时可以直接按事务ID把一次完整的请求生命线拉出来,一眼看到是建立连接阶段失败、还是发送阶段失败、还是读取阶段失败。
9.2 不要过度设计错误提示
我记得自己早期写网络模块时特别执着于把每个错误场景都配上精美的用户提示对话框,结果弹窗逻辑越写越复杂,最后用户抱怨"一天到晚弹窗,烦死了"。后来我换了一种思路:能自动恢复的静默恢复,恢复不了才提示,提示也只提示一次,并且给出下一步可操作的建议。 用户不是IT人员,他需要的是"怎么做才对"而不是"哪里坏了"。
9.3 关于MFC皮肤库的一点关联想法
这里提一嘴热门话题里反复出现的"mfc皮肤库实现方法"。网络错误处理得好不好,决定了程序崩溃的底限;界面好不好看,决定了用户心情的上限。但我想说的是,再好看的界面,一断网就卡死,用户也会把你拉黑。 我见过不少项目,皮肤库换了一套又一套,Button美化得花里胡哨,可网络异常处理一塌糊涂,现场运维天天当救火队员。所以我对MFC程序的建议永远是:先保命,再变美。网络异常处理是整个程序稳定性的最后一道堤坝,这关过了,你再考虑怎么在界面上多下功夫。
如果在你的项目里也遇到了 CInternetException 相关的诡异问题,欢迎把具体场景和错误码发出来讨论。排查网络异常这事儿,经常是"一人踩坑,众人受益"——我这边好几种问题的解法,最初也都是从社区朋友的现场描述里得到灵感的。
