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工具链进行了大调整,到了VS2012,C++工程里的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 | 第三方库 | 商业项目需购买授权 | 中,需处理编译冲突 | 一般,代码量大难黑盒排查 | 大型项目、多接口长期维护 |
| 手写SOAP+WinHTTP | WinHTTP系统组件 | 无 | 好 | 最好,报文可见可抓包 | 接口少、快速交付、老工程集成 |
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 version="1.0" encoding="utf-8"?> <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <QueryUser xmlns="http://tempuri.org/"> <userName>admin</userName> </QueryUser> </soap:Body> </soap:Envelope>这里有个非常重要的细节:Body中具体方法节点(QueryUser)上的xmlns,并不总是http://tempuri.org/,它必须和WSDL里定义的targetNamespace一致。很多初学者报文格式看着没问题,但服务端就是返回“操作无法识别”之类的错误,八成就是这个命名空间对不上。targetNamespace相当于方法的“身份证所属机构”,服务端靠它把请求路由到正确的业务处理代码上。
2.2 两个HTTP头别填错
报文本身只是请求体,要让服务端正确处理,HTTP头里的两个字段也必须准。一个是Content-Type,一般固定是text/xml; charset=utf-8,需要注意有的服务端只认text/xml不认application/soap+xml,这跟服务端用的SOAP版本有关,老一点的基于SOAP 1.1的服务基本只认text/xml。
另一个是SOAPAction。这个东西比较玄学,不同服务端要求不一样。有的服务端不校验SOAPAction,随便填都能过;有的服务端则严格要求必须等于WSDL里定义的soapAction值。判断方法是打开WSDL文件,搜索soapAction关键字:
<wsdl:operation name="QueryUser"> <soap:operation soapAction="http://tempuri.org/IUserService/QueryUser" style="document" /> </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:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <soap:Fault> <faultcode>soap:Server</faultcode> <faultstring>用户名不存在</faultstring> <detail> <e:Exception xmlns:e="http://tempuri.org/"> <e:Message>用户admin未注册</e:Message> </e:Exception> </detail> </soap:Fault> </soap:Body> </soap:Envelope>联调时遇到错误,第一步先去拿faultstring,这里面的内容通常就是服务端业务层抛出的具体原因。别在HTTP状态码上纠结,那只是运输层的“包裹破损标记”,真正的原因是包裹里的那张留言条。
3. 用WinHTTP在MFC里发送SOAP请求:完整可抄代码
3.1 工程配置
先说我用的环境:VS2015,MFC工程,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, // 服务器地址,如 L"192.168.1.10" INTERNET_PORT nPort, // 端口,HTTP为80,HTTPS为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(L"MFC-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, L"POST", 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( L"Content-Type: text/xml; charset=utf-8\r\n" L"SOAPAction: \"%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, L"Result", 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:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns2="http://tempuri.org/"> <soap:Body> <ns2:QueryUserResponse> <ns2:QueryUserResult> <Name>张三</Name> <Age>25</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; CComPtr<IXMLDOMDocument2> 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); CComPtr<IXMLDOMNode> 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_DOMDocument(MSXML 3.0)。MSXML 6.0对XPath的支持更好,安全性和性能也更好。
- get_text获取的是节点内全部文本内容。上面例子里如果调用ParseSoapResponse(strXml, L"QueryUserResult", 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 version="1.0" encoding="utf-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"&", L"&"); strOut.Replace(L"<", L"<"); strOut.Replace(L">", L">"); strOut.Replace(L"\"", L"""); strOut.Replace(L"'", L"'"); return strOut; }顺序很重要,必须先替换&,否则会把其他转义字符里的&再替换一遍,生成类似<的错误内容。
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(L"MFC-SoapClient/1.0", WINHTTP_ACCESS_TYPE_NAMED_PROXY, L"http://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\"?>" L"<soap:Envelope " L"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">" L"<soap:Body>" L"<QueryUser xmlns=\"http://tempuri.org/\">" L"<userName>%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看HTTP+XML确实没有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工程来说,比什么花哨的架构都管用。