
1. MFC接Web Service的两条岔路先想清楚再动手1.1 旧VS向导生成的代理类为什么越来越不靠谱先交代背景最近接了个活儿一台老设备配套的MFC客户端要做功能升级需要调用Java后台的一个Web Service接口。工程是用VS2010建的后来一直在VS2015里维护界面层、数据库层、串口通信层都历史包袱沉重唯独缺少和Web Service打交道的代码。刚开始我第一反应是找VS里的“添加Web引用”功能心想这玩意儿以前不是挺方便吗填个WSDL地址就能自动生成C代理类。结果翻了一圈发现情况远没有想的那么简单。VS2003、VS2005时代C项目确实能通过向导生成基于SOAP的Web Service代理类底层用的是ATL Server里的SoapClient机制生成一堆诸如CSoapClient那样的类。但VS2010之后微软对C的Web Service工具链进行了大调整到了VS2012C工程里的Web引用向导基本被拿掉了。也就是说老资料里的“加一个Web Reference填个URL点确定”这套操作在新版VS里根本找不到入口。就算你手工把以前生成的代理类代码拷进现有工程它依赖的atlsoap.h等头文件在不同版本环境下的表现也不一致编译期各种莫名其妙的C4xxx警告和链接错误能把人折腾疯。更关键的是一旦服务端WSDL发生变更你没有工具重新生成代理类就只能手工去改那一大坨模板代码维护成本会随时间推移越来越大。所以对老MFC项目来说这条路除非你一直锁死VS2008否则基本是走不远的。1.2 gSOAP、WinHTTP手动构造怎么选排除掉VS向导方案之后剩下的主流选择其实就两条引入gSOAP库或者完全手写SOAP报文再通过WinHTTP/WinInet发送。gSOAP是老牌C/C Web Service框架支持C和C可以基于WSDL生成强类型客户端代码调用起来确实像在调本地函数。但引入它需要仔细权衡授权问题绕不开。gSOAP的GPL和商业授权是两套公司项目不想开源就必须买商业授权这通常需要走法务和采购流程不是个人拍板能定的。构建复杂。wsdl2h先生成头文件soapcpp2再生成客户端桩代码然后还需要把runtime源码编进工程。MFC工程里混入这套东西后宏定义、预处理、CRT版本冲突概率不低处理起来非常考验经验。工程侵入性大。一批新文件加到工程里改项目属性可能还要调整公共头文件的包含顺序对一个“只想加一个查询功能”的老客户端来说风险收益比不够好。那么剩下的就是手写SOAP报文用WinHTTP发送再用MSXML解析响应。这个方案的好处非常实际不引入任何第三方库纯Windows SDK能力VS2010到VS2022都能编译报文是纯文本出了问题抓包看得明明白白职责边界清晰整个SOAP逻辑可以隔离在一个类里以后真要换方案也不会动到界面层代码。下面这张表是我选型时的对比记录方案依赖授权成本VS兼容性联调排错体验适合场景VS向导生成代理类atlsoap.h 等旧组件无VS2012后基本不可用差自动生成代码不透明仅限老版本VS旧工程gSOAP第三方库商业项目需购买授权中需处理编译冲突一般代码量大难黑盒排查大型项目、多接口长期维护手写SOAPWinHTTPWinHTTP系统组件无好最好报文可见可抓包接口少、快速交付、老工程集成1.3 我选型的判断标准我的选型判断标准其实就三条。第一依赖最小化。老MFC工程能不能稳定构建是第一优先级一个新功能不应该逼着整个工程动手术。第二可调试性。Web Service联调时报文对与不对一眼就能看出来手写方案下的请求串和响应串都能打印出来出问题能直接定位是HTTP层还是XML层。第三可替换性。SOAP交互代码必须封装在独立模块里接口调用方不直接接触XML细节将来服务端如果改成REST接口替换成本可控。基于这三点我最终选择了手写SOAP加WinHTTP加MSXML的组合。后面的文章内容就是我实际跑通这整套流程的记录包含完整可复制的代码以及那些资料里不会明说但实际联调时一定会遇到的坑。2. 动手前先把SOAP报文是什么东西搞明白2.1 SOAP信封的骨架很多从MFC起步的开发者一看到SOAP就头皮发麻觉得是个特别重的协议。实际上SOAP本质上就是一个带固定格式的XML消息通过HTTP协议POST到服务端服务端处理完再把结果用另一个XML消息返回。SOAP本身不发明任何网络传输机制它只是规定了XML的“信封”结构。拿快递来类比SOAP Envelope是快递盒soap:Header是盒子上的可选运单标签soap:Body就是里面真正的货物。服务端关心的是Body里的内容而Envelope负责声明这个内容是什么格式、遵守什么命名空间约定。一个典型的SOAP请求报文长这样?xml version1.0 encodingutf-8? soap:Envelope xmlns:soaphttp://schemas.xmlsoap.org/soap/envelope/ soap:Body QueryUser xmlnshttp://tempuri.org/ userNameadmin/userName /QueryUser /soap:Body /soap:Envelope这里有个非常重要的细节Body中具体方法节点QueryUser上的xmlns并不总是http://tempuri.org/它必须和WSDL里定义的targetNamespace一致。很多初学者报文格式看着没问题但服务端就是返回“操作无法识别”之类的错误八成就是这个命名空间对不上。targetNamespace相当于方法的“身份证所属机构”服务端靠它把请求路由到正确的业务处理代码上。2.2 两个HTTP头别填错报文本身只是请求体要让服务端正确处理HTTP头里的两个字段也必须准。一个是Content-Type一般固定是text/xml; charsetutf-8需要注意有的服务端只认text/xml不认application/soapxml这跟服务端用的SOAP版本有关老一点的基于SOAP 1.1的服务基本只认text/xml。另一个是SOAPAction。这个东西比较玄学不同服务端要求不一样。有的服务端不校验SOAPAction随便填都能过有的服务端则严格要求必须等于WSDL里定义的soapAction值。判断方法是打开WSDL文件搜索soapAction关键字wsdl:operation nameQueryUser soap:operation soapActionhttp://tempuri.org/IUserService/QueryUser styledocument / /wsdl:operation上面例子里的soapAction值就是要填到HTTP头里的字符串。注意有些服务端要求SOAPAction外面的双引号必须带上习惯上我都会拼成SOAPAction: http://tempuri.org/IUserService/QueryUser这样不管对方服务端是否严格要求引号都能兼容。2.3 SOAP Fault服务端报错时到底返回了什么当服务端处理失败时它不是简单地返回一个HTTP 500就完事而是会在响应正文里放一个SOAP Fault节点。刚开始联调时我不太会看这个节点遇到错误只知道HTTP状态码是500一度以为是自己HTTP请求发送的问题结果排查半天发现是服务端内部抛了异常。一个典型SOAP Fault长这样soap:Envelope xmlns:soaphttp://schemas.xmlsoap.org/soap/envelope/ soap:Body soap:Fault faultcodesoap:Server/faultcode faultstring用户名不存在/faultstring detail e:Exception xmlns:ehttp://tempuri.org/ e:Message用户admin未注册/e:Message /e:Exception /detail /soap:Fault /soap:Body /soap:Envelope联调时遇到错误第一步先去拿faultstring这里面的内容通常就是服务端业务层抛出的具体原因。别在HTTP状态码上纠结那只是运输层的“包裹破损标记”真正的原因是包裹里的那张留言条。3. 用WinHTTP在MFC里发送SOAP请求完整可抄代码3.1 工程配置先说我用的环境VS2015MFC工程Unicode字符集。WinHTTP是Windows系统自带的HTTP栈API不需要安装额外组件只需要在工程里引入头文件和依赖库。在要用到WinHTTP的源文件顶部加#include winhttp.h #pragma comment(lib, winhttp.lib)如果工程是预编译头方式建议把winhttp.h放到stdafx.h里统一包含避免每个文件都写一遍。连接库的#program也可以放到stdafx.h末尾这样整个项目所有模块都能直接使用WinHTTP API。3.2 封装一个SendSoapRequest函数网上讲WinHTTP的代码不少但很多是控制台Demo放到MFC工程里要么字符集不匹配要么缺少错误处理。我实际用下来的核心函数是下面这个参数含义都写在注释里了直接复制到工程里就能用CStringA Utf8FromCString(const CString strSrc) { if (strSrc.IsEmpty()) return CStringA(); int nLen ::WideCharToMultiByte(CP_UTF8, 0, strSrc, -1, NULL, 0, NULL, NULL); if (nLen 0) return CStringA(); CStringA strOut; char* pBuf strOut.GetBuffer(nLen); ::WideCharToMultiByte(CP_UTF8, 0, strSrc, -1, pBuf, nLen, NULL, NULL); strOut.ReleaseBuffer(nLen - 1); return strOut; } CString CStringFromUtf8(const CStringA strSrc) { if (strSrc.IsEmpty()) return CString(); int nLen ::MultiByteToWideChar(CP_UTF8, 0, strSrc, -1, NULL, 0); if (nLen 0) return CString(); CString strOut; wchar_t* pBuf strOut.GetBuffer(nLen); ::MultiByteToWideChar(CP_UTF8, 0, strSrc, -1, pBuf, nLen); strOut.ReleaseBuffer(nLen - 1); return strOut; } BOOL SendSoapRequest( const CString strServer, // 服务器地址如 L192.168.1.10 INTERNET_PORT nPort, // 端口HTTP为80HTTPS为443 const CString strPath, // 服务路径如 L/services/UserService const CString strAction, // SOAPAction值 const CString strRequestXml, // 完整的SOAP请求报文 CStringA strResponseUtf8, // 输出的响应内容UTF-8字节 DWORD dwTimeoutMs 30000) // 超时时间 { strResponseUtf8.Empty(); // 1. 创建会话句柄 HINTERNET hSession ::WinHttpOpen(LMFC-SoapClient/1.0, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (NULL hSession) return FALSE; // 2. 创建连接句柄 HINTERNET hConnect ::WinHttpConnect(hSession, strServer, nPort, 0); if (NULL hConnect) { ::WinHttpCloseHandle(hSession); return FALSE; } // 3. 创建请求句柄 HINTERNET hRequest ::WinHttpOpenRequest(hConnect, LPOST, strPath, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, (nPort 443) ? WINHTTP_FLAG_SECURE : 0); if (NULL hRequest) { ::WinHttpCloseHandle(hConnect); ::WinHttpCloseHandle(hSession); return FALSE; } // 4. 设置超时 ::WinHttpSetTimeouts(hRequest, dwTimeoutMs, dwTimeoutMs, dwTimeoutMs, dwTimeoutMs); // 5. 添加HTTP头 CString strHeaders; strHeaders.Format( LContent-Type: text/xml; charsetutf-8\r\n LSOAPAction: \%s\, strAction); ::WinHttpAddRequestHeaders(hRequest, strHeaders, -1, WINHTTP_ADDREQ_FLAG_REPLACE); // 6. 发送请求体体必须是UTF-8字节流 CStringA strUtf8 Utf8FromCString(strRequestXml); DWORD dwBodyLen (DWORD)strUtf8.GetLength(); if (!::WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, (LPVOID)(LPCSTR)strUtf8, dwBodyLen, dwBodyLen, 0)) { ::WinHttpCloseHandle(hRequest); ::WinHttpCloseHandle(hConnect); ::WinHttpCloseHandle(hSession); return FALSE; } // 7. 接收响应 if (!::WinHttpReceiveResponse(hRequest, NULL)) { ::WinHttpCloseHandle(hRequest); ::WinHttpCloseHandle(hConnect); ::WinHttpCloseHandle(hSession); return FALSE; } // 8. 查询HTTP状态码用于日志定位 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); // 9. 分块读取响应内容 char szBuf[4096] { 0 }; DWORD dwRead 0; for (;;) { if (!::WinHttpReadData(hRequest, szBuf, sizeof(szBuf) - 1, dwRead)) { break; } if (dwRead 0) break; szBuf[dwRead] \0; strResponseUtf8 szBuf; } ::WinHttpCloseHandle(hRequest); ::WinHttpCloseHandle(hConnect); ::WinHttpCloseHandle(hSession); return (dwStatusCode 200 dwStatusCode 300); }3.3 这几个关键点的设计原因很多人第一次看这段代码会疑惑为什么第6步要单独做一次UTF-8转换直接把请求XML塞给WinHttpSendRequest不行吗这里必须说明WinHttpSendRequest发送的是字节流不是字符串。MFC的CString在Unicode字符集下是UTF-16编码如果直接把CString内部缓冲地址传进去服务端按UTF-8解析收到的字节流中文和其他非ASCII字符就全乱码了。Utf8FromCString函数用WideCharToMultiByte把宽字符转成UTF-8字节序列是处理Unicode和UTF-8转换的标准做法比CW2A宏更可控不会因为作用域和栈空间出问题。第4步的WinHttpSetTimeouts有四个参数分别是解析DNS超时、建立连接超时、发送数据超时、接收数据超时。我统一传了同一个值实际项目里如果服务端处理时间较长建议把最后一个参数接收超时单独调大比如查询报表类接口可以放到60秒而普通查询接口30秒足够。第8步处理HTTP状态码时有个细节即使状态码是500我也并不直接返回失败而是先把响应体读回来。因为SOAP Fault通常就藏在500响应的Body里读出来才能看到服务端具体的错误信息。如果一看到5xx就直接return排查问题会少掉最重要的一手信息。3.4 在MFC界面线程中同步调用的后果如果直接把SendSoapRequest函数放在按钮的OnBnClicked事件里调用请求发出后界面会整个卡死直到超时或响应返回。这是因为网络IO是阻塞式的UI线程一旦阻塞就无法处理窗口消息界面自然就“假死”了。解决办法有两个一是封装一个工作线程来发请求完成后PostMessage给主窗口二是使用异步WinHTTP回调。对MFC工程来说工作线程加PostMessage是最直观、最容易维护的方式。下面是我用的线程结构#define WM_SOAP_RESULT_MSG (WM_USER 2001) typedef struct _SOAP_REQ_PARAM { CString strServer; INTERNET_PORT nPort; CString strPath; CString strAction; CString strRequestXml; HWND hNotifyWnd; } SOAP_REQ_PARAM; UINT SoapWorkerThread(LPVOID pParam) { ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); SOAP_REQ_PARAM* pReq (SOAP_REQ_PARAM*)pParam; CStringA strResponseA; BOOL bOk SendSoapRequest( pReq-strServer, pReq-nPort, pReq-strPath, pReq-strAction, pReq-strRequestXml, strResponseA); CString strResponseW CStringFromUtf8(strResponseA); CString strResult; if (bOk) { ParseSoapResponse(strResponseW, LResult, strResult); } // 把结果通过消息投递给UI线程 SOAP_RESULT* pResult new SOAP_RESULT; pResult-bSuccess bOk; pResult-strResult strResult; pResult-strRawResponse strResponseW; ::PostMessage(pReq-hNotifyWnd, WM_SOAP_RESULT_MSG, (WPARAM)bOk, (LPARAM)pResult); delete pReq; ::CoUninitialize(); return 0; }这个线程函数里有个容易踩的坑pParam指向的参数是在主线程栈上还是堆上如果直接在按钮事件里定义一个SOAP_REQ_PARAM局部变量再把地址传给_beginthreadex线程还没开始执行函数已经返回局部变量就失效了。正确做法是用new在堆上创建参数在线程函数内部结束前delete。上面代码已经做了这个处理。4. 响应解析MSXML按命名空间取值的正确姿势4.1 为什么用MSXML而不是自己写字符串查找响应拿到手之后下一步是把XML里的业务数据提取出来。有些MFC老工程师喜欢直接找字符串位置比如用CString::Find找某个标签的起始位置再截取内容。这种思路在SOAP场景下风险很高命名空间前缀可能变、返回字段顺序可能变、标签内可能嵌套其他标签稍微复杂一点的结构就会截错。MSXML是Windows系统自带的XML解析组件从Windows 2000开始就是系统组件了MFC程序通过COM接口直接调用不依赖第三方库。工程里用下面两行引入#import msxml6.dll named_guids raw_interfaces_only using namespace MSXML2;或者按传统COM方式包含头文件和库#include msxml6.h #pragma comment(lib, msxml6.lib)两种方式各有优劣。#import方式会自动生成智能指针包装类写起来省事但生成的tlh文件在每次编译时都会重新生成偶尔会和预编译头产生冲突。头文件方式更传统配合CComPtr使用更符合MFC工程习惯。我这里用的是后者。4.2 loadXML之前的COM初始化MSXML是COM组件使用前必须先初始化COM。MFC工程里主线程通常在CWinApp::InitInstance中已经隐式初始化过但工作线程中必须自己显式初始化。这也是很多人在后台线程里调用MSXML时莫名其妙崩溃的原因。一个健壮的初始化写法是HRESULT hr ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); if (FAILED(hr) hr ! RPC_E_CHANGED_MODE) { return false; }这里有个细节CoInitializeEx返回RPC_E_CHANGED_MODE说明当前线程已经用其他模型初始化过COM了这种情况下不需要也不能再调用CoUninitialize否则会破坏线程原有状态。只有hr为S_OK时才需要后续配对调用CoUninitialize。4.3 local-name()绕开命名空间的取节点技巧SOAP响应XML和普通XML最大的不同就是到处是命名空间。看下面这个真实响应soap:Envelope xmlns:soaphttp://schemas.xmlsoap.org/soap/envelope/ xmlns:ns2http://tempuri.org/ soap:Body ns2:QueryUserResponse ns2:QueryUserResult Name张三/Name Age25/Age /ns2:QueryUserResult /ns2:QueryUserResponse /soap:Body /soap:Envelope如果直接用selectSingleNode(//QueryUserResult)结果通常查不到。原因很简单在XML里没有前缀的节点属于“无命名空间”而QueryUserResult实际属于http://tempuri.org/这个命名空间所以带默认命名空间匹配的XPath找不到它。解决方案是在XPath里用local-name()函数让路径匹配时不关心节点前缀bool ParseSoapResponse( const CString strXml, const CString strLocalName, CString strValue) { HRESULT hr ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); bool bNeedUninit SUCCEEDED(hr); if (FAILED(hr) hr ! RPC_E_CHANGED_MODE) return false; CComPtrIXMLDOMDocument2 spDoc; hr spDoc.CoCreateInstance(CLSID_DOMDocument60); if (FAILED(hr)) { if (bNeedUninit) ::CoUninitialize(); return false; } VARIANT_BOOL bSuccess VARIANT_FALSE; hr spDoc-loadXML(CComBSTR(strXml), bSuccess); if (FAILED(hr) || bSuccess ! VARIANT_TRUE) { if (bNeedUninit) ::CoUninitialize(); return false; } CString strXPath; strXPath.Format(L//*[local-name()%s], strLocalName); CComPtrIXMLDOMNode spNode; hr spDoc-selectSingleNode(CComBSTR(strXPath), spNode); if (FAILED(hr) || !spNode) { if (bNeedUninit) ::CoUninitialize(); return false; } CComBSTR bstrValue; hr spNode-get_text(bstrValue); if (SUCCEEDED(hr) bstrValue.m_str) { strValue bstrValue.m_str; } if (bNeedUninit) ::CoUninitialize(); return true; }这里有几个值得注意的地方用CLSID_DOMDocument60指定MSXML 6.0而不是老的CLSID_DOMDocumentMSXML 3.0。MSXML 6.0对XPath的支持更好安全性和性能也更好。get_text获取的是节点内全部文本内容。上面例子里如果调用ParseSoapResponse(strXml, LQueryUserResult, strValue)拿到的是“张三25”而不是单独的“张三”。如果要取子元素需要递归遍历或更精确的XPath。用完CComPtr变量后最好让其先释放再调用CoUninitialize。上面的代码在return之前局部变量会析构但因为CComPtr析构发生在函数栈释放时理论上晚于CoUninitialize可能造成COM对象在未初始化状态下释放。在MFC单线程里一般问题不大但严谨起见可以在CoUninitialize前显式调用spNode.Release()和spDoc.Release()。4.4 更精确的XPath写法如果需要精确到某个层级可以组合local-name和路径关系。比如要取QueryUserResponse下的QueryUserResult再取其中的Name字段可以写strXPath.Format( L//*[local-name()QueryUserResult]/*[local-name()Name]);不过这种写法在只有一个同名节点时略啰嗦直接 //*[local-name()Name] 就够了。如果响应里存在多个层级的同名节点精确路径就非常重要了。实际使用中我会先抓响应日志看一眼结构再决定用哪种XPath。5. 实测下来的坑编码、特殊字符、证书和多线程5.1 中文参数变成乱码的根源这个问题我踩过一次狠的。第一次联调时用CString直接拼接SOAP报文然后转成CStringA发送服务端返回的参数永远是“”或者干脆报错。排查了很久才意识到CStringA的默认转换用的是系统当前代码页中文Windows下是GBK而SOAP报文声明的是UTF-8编码服务端也按UTF-8解码两边编码对不上中文必然乱。处理办法就是前文代码里的Utf8FromCString函数所有请求报文在发送前统一转成UTF-8字节流。同时确认报文头部的XML声明也是?xml version1.0 encodingutf-8?两份编码声明必须一致否则有些严格的服务端会直接拒绝请求。响应体的解码同样要做好反向转换。WinHttpReadData读出来的是UTF-8字节要转成CString给界面展示或继续解析调用CStringFromUtf8函数即可。注意不要直接把CStringA赋给CString那会按ANSI代码页转换中文长字符会显示成乱码。5.2 XML特殊字符转义只要参数是用户输入内容就可能包含XML特殊字符。、、、双引号、单引号五个字符在XML里有特殊含义直接拼进报文里会导致报文不是合法XML服务端解析报错。一个简单的转义函数CString XmlEscape(const CString strSrc) { CString strOut strSrc; strOut.Replace(L, Lamp;); strOut.Replace(L, Llt;); strOut.Replace(L, Lgt;); strOut.Replace(L\, Lquot;); strOut.Replace(L, Lapos;); return strOut; }顺序很重要必须先替换否则会把其他转义字符里的再替换一遍生成类似lt;的错误内容。5.3 HTTPS证书和代理环境如果服务端是HTTPS地址WinHttpOpenRequest最后一个参数要传WINHTTP_FLAG_SECURE。自签名证书在测试环境非常常见默认情况下WinHTTP会校验失败并拒绝连接。测试阶段可以通过设置安全选项暂时忽略证书错误DWORD dwFlags SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_CERT_CN_INVALID; ::WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURITY_FLAGS, dwFlags, sizeof(dwFlags));这段代码只能放在测试环境联调用生产环境绝对不能放开证书校验否则等于让攻击者可以随意伪造服务器身份整个通信就裸奔了合规上也说不过去。公司网络环境这块我的建议是别偷懒直接给客户端做一个“代理设置”入口毕竟生产环境很多用户都在内网离开代理服务根本连不上外网服务。WinHttpOpen第二个参数传WINHTTP_ACCESS_TYPE_DEFAULT_PROXY时WinHTTP会尝试读取IE/系统代理设置这在大多数办公网里能直接生效。如果实际网络环境需要显式代理可以改传代理地址HINTERNET hSession ::WinHttpOpen(LMFC-SoapClient/1.0, WINHTTP_ACCESS_TYPE_NAMED_PROXY, Lhttp://proxy.corp.example.com:8080, WINHTTP_NO_PROXY_BYPASS, 0);但这里涉及具体公司网络地址和端口属于部署配置建议把代理参数做成可配置项程序启动时从配置文件读入避免硬编码到代码里。5.4 工作线程访问COM和UI控件的铁律后台线程做完网络请求和XML解析后绝对不能直接调用界面控件的SetWindowText之类的方法。MFC窗口对象不是线程安全的跨线程直接操作轻则显示不同步重则内存访问冲突崩溃。标准做法是PostMessage把结果发回UI线程。这里有个额外提醒如果PostMessage的LPARAM是new出来的指针接收方在处理完消息后必须delete否则每次请求都泄漏一块内存。长时间运行的客户端程序会被这种小泄漏慢慢拖垮。还有一个坑是#using和#import生成的COM智能指针在DLL边界上的问题。如果MFC程序是静态链接MFC库方式并且用了#import生成的包装类有时候会出现调试对话框报“未处理的异常试图调用托管代码”之类的情况。我现在都用CComPtr加msxml6.h头文件方式没再遇到这个问题。6. 后续演进从“能调通”到“好维护”6.1 把SOAP请求体参数化按上面的代码做每个接口都手写拼接XML会非常繁琐而且容易出错。我实际工程里的做法是封装一个简易的构造函数把可变参数传入固定部分写死CString BuildQueryUserRequest(const CString strUserName) { CString strXml; strXml.Format( L?xml version\1.0\ encoding\utf-8\? Lsoap:Envelope Lxmlns:soap\http://schemas.xmlsoap.org/soap/envelope/\ Lsoap:Body LQueryUser xmlns\http://tempuri.org/\ LuserName%s/userName L/QueryUser L/soap:Body L/soap:Envelope, XmlEscape(strUserName)); return strXml; }用Format的好处是占位符清晰后续改了参数数量也好维护。注意凡是用户输入型参数都必须经过XmlEscape再拼进去。接口数量一旦多起来更推荐用一个小型XML构建辅助类来生成报文而不是无限写Format。具体看项目接口数量而定五六个接口以内Format完全够用。6.2 日志和抓包是联调的左膀右臂手写SOAP方案有个巨大优势所有交互内容都是文本。我在SendSoapRequest函数里加了两个日志点请求发出前记录请求报文响应回来后记录响应报文和HTTP状态码写入本地日志文件。这样服务端说“我没收到”的时候我可以直接反驳说请求发了、报文是什么服务端说“我返回了”但是客户端报错时日志里也有原始响应能核对。联调效率至少提升一倍。void WriteSoapLog(const CString strTag, const CString strContent) { CString strLogPath; strLogPath.Format(L%s\\SoapLog_%s.txt, (LPCWSTR)GetExeDirectory(), CTime::GetCurrentTime().Format(L%Y%m%d)); CStdioFile file; if (file.Open(strLogPath, CFile::modeCreate | CFile::modeNoTruncate | CFile::modeWrite)) { file.SeekToEnd(); CString strLine; strLine.Format(L[%s] %s\r\n, strTag, strContent); file.WriteString(strLine); file.Close(); } }抓包工具方面Fiddler配合WinHTTP需要设置一下。WinHTTP默认不信任Fiddler的代理证书所以我在测试环境用Wireshark抓包看报文更省事。不过Wireshark看HTTPXML确实没有Fiddler那么直观如果条件允许还是建议在测试环境给WinHTTP临时指向Fiddler代理并把证书装进系统证书库看到的效果会好很多。6.3 什么时候该考虑换技术栈手写SOAP方案适合接口数量少、报文结构相对稳定的场景。如果服务端接口有二三十个字段又多又嵌套手写报文的工作量会急剧膨胀而且容易在字段映射上出错。这种情况我建议认真评估gSOAP哪怕需要购买商业授权省下来的开发维护人力很可能比授权费更值。另一个更趋势化的方向是推动服务端提供REST/JSON接口。如果对方系统没有历史约束说服他们开一组HTTP JSON接口MFC这边用简单的HTTP请求加JSON解析库比如jsoncpp或cJSON就能搞定可比维护SOAP报文轻快多了。我个人在实际项目里的体会是技术选型没有绝对的好坏只有合不合适。当一个方案让团队在联调时花大量时间在报文纠错上而且这个问题反复出现就该停下来重新审视一下成本收益比而不是硬着头皮继续堆代码。最后再分享一个细节。MFC工程里集成这套SOAP调用逻辑建议把所有实现都塞进一个独立的类文件比如CSoapService对外只暴露一两个业务方法比如BOOL QueryUser(const CString strName, CString strResult)。界面层完全不需要知道内部用的是SOAP、HTTP还是别的协议。这样将来无论换技术栈还是修问题改动范围都限定在一个文件里这种边界清晰的写法对维护了七八年的老MFC工程来说比什么花哨的架构都管用。