☰
VC老工程对接WebService:手写SOAP报文用WinHTTP+MSXML实现
2026/10/13 19:03:07 网站建设 项目流程

简介:面向需要在Visual C++环境中集成Web Service功能的开发者,这份doc文档是一个很实用的入门示例,尤其适合经常在MFC对话框程序中接入远程接口的工程师。示例以短信发送服务为场景,完整演示了如何利用COM组件与SOAP协议完成远程服务调用。文中逐步讲解创建SOAP连接器、指定服务终结点、建立连接、设置SOAP动作、序列化消息、构造消息体与参数,以及发送并读取响应等环节,覆盖了调用Web Service的完整流程。同时,对每个关键接口和函数的作用都做了简要说明,并展示了用户名、密码、电话号码、短信内容等参数在SOAP消息中的构造方式,方便读者对照代码理解。资源包仅含1个doc文件,大小28KB,属于轻量级参考文档,适合有一定VC++基础、希望快速掌握Web Service集成技巧的开发者。该示例发布以来已有179人学习,具备一定参考价值,读者可直接参考其中的实现逻辑,将短信发送、数据查询等外部服务快速集成到自己的桌面程序中。文档结构清晰,关键代码附有注释,无论是入门学习还是日常开发参考,都能帮助读者快速理清基于SOAP的远程调用实现要点。

1. 老 C++ 程序对接 WebService,为什么我最后选择了手写 SOAP

做 VC 调用 WEBSERVICE 这件事,看起来是十几年前的老话题,但今天依然有一堆维护 MFC/Win32 老工程的人被它卡住。常见场景是旧客户端要对接新上的服务端接口,服务端只暴露 asmx/wcf 的 SOAP 地址,而本地工程还停留在原生 C++ 环境,既不能直接引用托管程序集,又不方便装第三方运行时。网上一搜旧帖子,要么让你装一个早已停止维护的组件,要么让你开 /clr 走托管中间层,最后不是 DLL 注册失败,就是部署环境兼容性爆炸。我的选择是绕开所有额外组件,直接用 WinHTTP 发送 SOAP XML,再用 MSXML 解析返回结果。这条路不要求目标机器装任何新东西,排查问题也最直接。这篇笔记就把这个方案里涉及的路由参数、编码细节和常见坑一次说清楚。

2. 先别急着写代码:VC 调 WebService 的四个方案与选型依据

老工程要对接 WebService,很多人的第一反应是搜一个现成组件,装完就能用。但组件方案在一台机器能用、换一台就暴雷的情况,我碰到过不止一次。这里先把几种常见的走法逐个说清楚,再告诉你为什么我最后落到手写 SOAP。方案没有绝对好坏,只有和你的工程环境合不合。

2.1 老式 SOAP 组件:为什么只剩维持现状的价值

网上搜“VC 调用 WebService”,很大一部分结果指向老式 SOAP 客户端组件。它把 WebService 包装成 COM 对象,C++ 通过创建 SoapClient 对象,设置 WSDL 地址再调用方法。在早期系统上这确实是当时最省事的做法,很多 MFC 程序就是用它接的后端。组件会自动组装和解析 SOAP 消息,连命名空间映射都帮你处理好了,接入速度非常快。

但它的问题在于这套组件从停止更新以后,几乎成了悬空状态。新系统上安装时,经常需要注册多个 COM 组件,而且只支持 32 位进程。你的工程如果是 64 位编译,要么整体改成 32 位,要么自己做跨进程代理,这个复杂度远超手写 XML。另外它依赖的 XML 解析版本比较老,和新系统自带的新版本共存时,注册表经常被覆盖,导致原来能用的程序突然报“类未注册”。遇到这种环境问题,你都没法控制客户机器上的安装行为。

如果现有项目已经在用它,并且只在固定几台机器运行,那我不建议贸然替换,因为改动路径很长。但新项目或者要发布到未知环境的项目,最好不要选它。还有一点,组件内部生成的 SOAP 消息基本不可调,遇到服务端要求特殊认证头或自定义 Header 的时候,扩展能力也很弱。换到可手写消息的方案,反而更透明。

2.2 WSDL 代码生成工具:适合复杂 WSDL,但依赖链让你头疼

如果你的诉求是“我有 WSDL,能不能自动生成 C++ 代码”,最典型的路子是借助计算生成工具把 WSDL 翻译成 C/C++ 模板,常见的前端命令是 wsdl2h,后续再用代码生成器生成一组调用源码。对于有几十个字段、多层嵌套的复杂对象,用生成代码比手拼 XML 保险很多,结构体经过序列化后能一一对应到 XML element,字段漏写的情况会少一些。

但这套工具的学习成本至少需要一整个下午来摸索。你要先让解析器能正确读入 WSDL,再调整 SOAP 1.1/1.2 版本参数,最后把生成的多个 .cpp/.c 和 .h 文件丢进工程里。生成出的代码还会引用它自己运行时的一批源码,整个依赖关系是一张网。第一次编译时还要留意服务端 WSDL 里有没有 import 其他 Schema,如果没有完整拿到定义文件,生成的代码可能缺类型。

更麻烦的是,服务端接口发生向下兼容升级时,只要 WSDL 里声明变了,重新生成后方法名可能因为命名冲突被改掉。我遇到的实际例子是,旧方法名被加了一个序号后缀,结果整个工程十几处调用点都要跟着改。为了一个只有两三个接口的小模块,我并不想把构建流程绑定在一整套外部工具链上。它不是不好,只是对于一个简单对接需求来说太重。

2.3 C++/CLI 中间层:能绕路但把项目拖进托管世界

如果你的工程本身版本比较新,而且允许上 .NET,那么 C++/CLI 是一条捷径。做法是在解决方案里新建一个 C++/CLI 类库,在里面添加服务引用,编译器会生成代理类。原生 C++ 部分再调用这个类库的导出函数。因为代理类已经把类型转成了 .NET 对象,接口定义非常友好,方法返回值可以直接用一个结构体或者字符串。

但这个方案把“调用 WebService”问题变成了“跨托管与原生边界”问题。一旦打开 /clr,工程里很多老 C++ 代码会有兼容性限制,比如某些全局初始化、异常处理、静态库的链接方式都可能发生变化。我以前在一个 CMake 维护的原生工程里,只给附加的模块开了 /clr,结果构建顺序、调试符号全变了,连输出日志的调用栈都带上托管桥接帧,排查问题多了一层成本。部署端也要求机器有对应版本的 .NET 运行时,对于企业内网客户机来说,又增加一个依赖项。

所以在需求明确是“原生 C++ 程序要简单调一两个 SOAP 接口,且主要跑在老环境”时,我不太愿意用 /clr 把长期维护复杂度拉高。当然,如果整个工程本来就在往托管方向迁移,那直接用托管代码也许更合理,没必要用中间层折磨自己。

2.4 我推荐的组合:WinHTTP 发请求 + MSXML 解响应

排除了上面几条路,剩下的就是站在系统 API 的肩膀上直接干活。WinHTTP 负责发送 HTTP POST 请求,MSXML 负责把返回的 XML 解析成 DOM。这两个都是系统自带动态库,你的 VC 工程只要链接对应导入库、包含头文件就能用。调用服务的时候,看到的就是一条 HTTP 请求和一个 XML 字符串,任何环境问题都能从返回头和响应内容里定位,不需要猜第三方的内部行为。

选择 WinHTTP 而不是 WinINet,主要看中的是它在后台和服务场景下的表现。WinINet 为浏览器场景做很多自动行为,比如自动用系统代理、缓存重定向结果、对长连接有自己的管理约定;在 Windows 服务里面跑,容易出现代理与会话不一致的问题。WinHTTP 的默认行为接近普通服务器客户端库,一个会话一个连接,超时和重试都能通过 API 显式控制,这对程序稳定性很重要。MSXML6 则把返回 XML 加载成 DOM,之后用 selectSingleNode 走 XPath 取值,不用自己逐字符去匹配标签。

用这套方案需要掌握好 SOAP 信封里的命名空间映射。WSDL 里定义的 targetNamespace、SOAPAction 决定了请求头的值和 Body 里的命名空间前缀。写错一个,服务端可能返回 500,只有对比抓包才能看出差异。所以它可排查性高,不代表没有学习成本,只是把成本从“环境管理”挪到了“对 SOAP 协议本身的理解”上。下面一章,我从一个最小可用的实例开始,把完整调用链写出来。

2.5 四个方案的对比与选择条件

方案系统依赖64 位支持学习成本代码可维护性适合场景
老式 SOAP 组件COM 组件、旧版 XML 解析差低低旧系统维持现状
WSDL 代码生成工具自定义生成源文件良好高中复杂 WSDL、固定服务端
C++/CLI 中间层.NET 运行时良好中中工程已托管化
WinHTTP + MSXML系统自带良好中高少量接口、老工程稳定对接

选型上我的一般判断方法是:接口数量在一只手能数完,服务端变更频率不高,就走手写 WinHTTP;如果接口多到几十个、且服务端经常加字段,自动生成代码省下的体力更值得;如果整个工程将来都要迁移到 .NET,直接用 C++/CLI 过渡更合理。没有哪个方案全绿,关键是别在已经确定的工程形态里再引入一种新的运行时依赖。

3. 用 WinHTTP 在本地跑通最小 SOAP 请求:代码与参数

这一章我们抛开理论,实际写一个能跑的请求。以一个简单的 Add 接口为例,输入两个整数,返回一个整数。先构造 Envelope,再发送,最后解析返回结果。

3.1 拼一个最小 Envelope,先别管复杂类型

假定服务端方法的目标命名空间是 http://tempuri.org/,于是 SOAP 1.1 请求体长这样。

<?xml version="1.0" encoding="utf-8"?> <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <soap:Body> <Add xmlns="http://tempuri.org/"> <a>1</a> <b>2</b> </Add> </soap:Body> </soap:Envelope>

注意,这里<Add>上的 xmlns 是元素默认命名空间,值必须和 WSDL 里的 targetNamespace 一致。前缀叫什么不重要,服务端真正认的是 URI,不是前缀字符串。有些旧教程把前缀改成 tns 或者 ns1,只要 URI 对,请求都能通。SOAPAction 要从 WSDL 的 binding 中复制,通常是http://tempuri.org/Add这类写法。

在 VC 代码里构建这个字符串,我一般用 std::wstring 一层层拼,避免混用窄宽字符:

#include <string> std::wstring BuildAddEnvelope(int a, int b) { std::wstring xml; xml += L"<?xml version=\"1.0\" encoding=\"utf-8\"?>"; xml += L"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"; xml += L"<soap:Body>"; xml += L"<Add xmlns=\"http://tempuri.org/\">"; xml += L"<a>" + std::to_wstring(a) + L"</a>"; xml += L"<b>" + std::to_wstring(b) + L"</b>"; xml += L"</Add>"; xml += L"</soap:Body>"; xml += L"</soap:Envelope>"; return xml; }

这段代码用 wstring 拼接,原因是为了后面统一转 UTF-8。XML 声明写的是 utf-8,所以实际发送的字节流必须是 UTF-8 编码。如果直接用 ANSI 字符串拼,中文字符会在这个环节就乱掉。to_wstring 负责把整数转成宽字符文本,和这里的编码链路兼容。

3.2 发送请求的正确姿势:HTTP 头、超时、证书

发送流程分四步:WinHttpOpen 创建会话句柄、WinHttpConnect 建立连接、WinHttpOpenRequest 创建请求、WinHttpSendRequest 发送。下面给出一个可以直接放进原生 C++ 工程的函数,入参是完整 URL、SOAPAction、UTF-8 编码的请求串,出参是响应原文。

#include <windows.h> #include <winhttp.h> #include <string> #pragma comment(lib, "winhttp.lib") BOOL SendSoapViaWinHttp( const std::wstring& url, // 例如 L"http://192.168.1.10/WebService.asmx" const std::wstring& soapAction, // 例如 L"http://tempuri.org/Add" const std::string& utf8Request, std::string& utf8Response) { wchar_t hostBuf[256] = {}; wchar_t pathBuf[1024] = {}; URL_COMPONENTS comp = {}; comp.dwStructSize = sizeof(comp); comp.lpszHostName = hostBuf; comp.dwHostNameLength = 256; comp.lpszUrlPath = pathBuf; comp.dwUrlPathLength = 1024; if (!WinHttpCrackUrl(url.c_str(), (DWORD)url.size(), 0, &comp)) return FALSE; bool useTls = (comp.nScheme == INTERNET_SCHEME_HTTPS); std::wstring host = hostBuf; std::wstring path = pathBuf; HINTERNET hSession = WinHttpOpen(L"VC-WebService-Client/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, NULL, 0); if (!hSession) return FALSE; // 超时参数:解析/连接/发送/接收,单位毫秒 WinHttpSetTimeouts(hSession, 5000, 5000, 5000, 10000); HINTERNET hConnect = WinHttpConnect(hSession, host.c_str(), comp.nPort, 0); if (!hConnect) { WinHttpCloseHandle(hSession); return FALSE; } DWORD flags = useTls ? WINHTTP_FLAG_SECURE : 0; HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", path.c_str(), NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return FALSE; } // SOAPAction 必须和 WSDL 里的 binding 一致,值最好带引号 std::wstring headers = L"Content-Type: text/xml; charset=utf-8\r\n" L"SOAPAction: \"" + soapAction + L"\"\r\n"; BOOL ok = WinHttpSendRequest(hRequest, headers.c_str(), (DWORD)-1L, (LPVOID)utf8Request.c_str(), (DWORD)utf8Request.size(), (DWORD)utf8Request.size(), 0); if (ok) ok = WinHttpReceiveResponse(hRequest, NULL); if (ok) { utf8Response.clear(); DWORD dwSize = 0; while (WinHttpQueryDataAvailable(hRequest, &dwSize) && dwSize > 0) { std::string buf(dwSize, '\0'); DWORD read = 0; if (WinHttpReadData(hRequest, &buf[0], dwSize, &read)) { utf8Response.append(buf.data(), read); } } } WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return ok; }

这个函数里 WinHttpCrackUrl 把形如http://host/Service.asmx的地址拆成主机名和路径,省去调用方手工解析。useTls由协议自动判断,如果是 https 请求,创建请求句柄时就要传WINHTTP_FLAG_SECURE,否则连接会失败在 TLS 握手阶段。WinHttpSetTimeouts这一段是容易被忽略的点,很多默认超时长达几十秒,界面会像卡死一样,所以我在会话创建后就设置成 5 秒解析、5 秒连接、5 秒发送、10 秒接收。

HTTP 头里Content-Type必须带charset=utf-8,不能只写text/xml。SOAPAction 我习惯用引号包起来,虽然很多服务端不强制,但这是 SOAP 1.1 规范里允许的写法,兼容性最好。响应读取用循环,是因为一次 WinHttpReadData 并不保证读完整段数据,QueryDataAvailable可以拿到当前可读的字节数,配合 read 参数避免缓冲区溢出。

3.3 解析响应:重命名空间前缀后按 XML 路径取值

拿到响应 XML 后,第一件事不是去看字符串,而是交给 MSXML 做成 DOM。这里用 MSXML6 的 COM 接口,在工程里需要提前#import "msxml6.dll",下面代码中的智能指针就来自这个导入。

#import "msxml6.dll" named_guids #include <comutil.h> HRESULT ParseAddResult(const std::string& utf8Response, int& result) { HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); if (hr != S_OK && hr != S_FALSE) return hr; MSXML2::IXMLDOMDocument2Ptr spDoc; hr = spDoc.CreateInstance(__uuidof(MSXML2::DOMDocument60)); if (FAILED(hr)) return hr; // 响应体先转成宽字符,再交给 loadXML,避免字符编码二次转换 std::wstring wsResp = Utf8ToWString(utf8Response); VARIANT_BOOL loaded = spDoc->loadXML(_bstr_t(wsResp.c_str())); if (loaded == VARIANT_FALSE) return E_FAIL; // 用 local-name() 忽略命名空间前缀,直接按叶子节点取文本 MSXML2::IXMLDOMNodePtr node = spDoc->selectSingleNode( _bstr_t(L"//*[local-name()='AddResult']")); if (!node) return E_POINTER; result = _wtoi(node->text); return S_OK; }

这里有一个很关键的经验:不要用//AddResult这样的 XPath 去找节点。如果服务端返回的 XML 用了默认命名空间,无前缀的路径名会被解释成“没有命名空间”,而实际节点属于服务端 targetNamespace,于是永远选不中。local-name()只看标签本地名,不关心前缀和默认命名空间,这是最省心、最多环境能通过的写法。

node->text是节点文本的宽字符表示,值就是结果整数。解析之前先用//*[local-name()='Fault']检查是否有 soap:Fault,如果有,就不要再提取业务字段了,直接把 faultstring 拿出来做错误信息。这个检查习惯可以避免你把错误 XML 当成正常结果去取值。

4. 参数与边界:编码、超时、HTTP/1.1 keep-alive 的现场调优

手写 SOAP 的方案核心是可控,但也意味着所有边界问题都暴露在代码里。这一章把最常踩的几个参数和边界条件单独拎出来讲。

4.1 中文入参与返回值的编码处理

中文是最容易翻车的点,而且翻车现场通常不在客户端,在服务端日志里才看得到。根本原因是 VC 工程里字符串的编码取决于编译设置。如果你在 MBCS 工程里用 CString 直接拼 XML,里面每个中文字符都是本机 ANSI 编码,而 XML 声明却写 utf-8,服务端按 UTF-8 解码后一定乱码。正确做法是统一先转成宽字符,再从宽字符转 UTF-8,不做第二次猜测。

std::string WStringToUtf8(const std::wstring& wstr) { if (wstr.empty()) return std::string(); int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), nullptr, 0, nullptr, nullptr); std::string result(size, '\0'); WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), &result[0], size, nullptr, nullptr); return result; } std::wstring Utf8ToWString(const std::string& utf8) { if (utf8.empty()) return std::wstring(); int size = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), nullptr, 0); std::wstring result(size, L'\0'); MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), (int)utf8.size(), &result[0], size); return result; }

WStringToUtf8 用于发送前转换,Utf8ToWString 用于响应解析前转换。这两个函数把字符编码的边界收在同一个地方,后续所有 XML 操作都用宽字符,避免代码里到处都是 MultiByteToWideChar。服务端如果返回 GBK 编码,多半是它的 XML 声明写的不是 utf-8,你可以根据声明动态选择转换目标编码,但现实中绝大多数 SOAP 服务都是 UTF-8,能提前确认就不需要运行时判断。

4.2 超时与重试参数怎么设,才不会让界面卡死

WinHTTP 的默认超时对桌面程序来说偏长,一旦网络不可达,调用函数会阻塞十几秒甚至更久。前面代码已经演示了 WinHttpSetTimeouts,四个参数分别是域名解析、建立连接、发送请求、接收响应,单位都是毫秒。经验值我会设成 5 秒连接超时、10 秒接收超时。如果服务端处理正常,普通小接口一般在 2 秒内返回;超过这个时间,多半是网络或服务端出了问题,再等下去只是把用户界面拖死。

重试要看接口是否幂等。比如查询类接口,超时后重试两三次问题不大;新增或下单类接口,重试可能导致重复数据,所以这类请求只做一次,宁可让上层提示用户人工确认,也不能盲目循环重发。

for (int i = 0; i < 3; ++i) { if (SendSoapViaWinHttp(url, soapAction, utf8Request, utf8Response)) { break; } Sleep(200 * (i + 1)); // 递增等待,避免连击 }

这段简单的重试循环只适合在工作线程里使用,不要直接在 UI 消息处理里调用。Sleep 在这里是演示节奏,真正常用的做法是把请求丢到一个业务线程池,回调里再刷新界面。WinHTTP 本身还支持异步回调,但异步模式引入了回调上下文和线程安全边界,代码复杂度会明显上升。对于接口数量不多的老工程,开一个工作线程同步调用是最容易维护的解法。

4.3 HTTPS 证书校验:自签名证书和私网地址的处理

内网 WebService 大量使用自签名证书或者不匹配域名的证书。默认情况下 WinHTTP 会严格校验证书,遇到这类地址直接返回错误,常见错误码是 0x80072f8d。解决办法是在创建请求句柄之后、发送请求之前,设置安全标志位,让 WinHTTP 忽略特定几类证书问题。

DWORD flags = SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_CERT_CN_INVALID; BOOL ok = WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURITY_FLAGS, &flags, sizeof(flags)); if (!ok) { // 查看 GetLastError(),通常是句柄状态不对 }

SECURITY_FLAG_IGNORE_UNKNOWN_CA 对应“证书颁发机构不受信任”,自签名证书就是这个情况。IGNORE_CERT_CN_INVALID 对应“证书中的域名和访问地址不一致”,很多内网地址用 IP 访问时都会触发。IGNORE_CERT_DATE_INVALID 是证书日期过期,通常只在开发机出现。这三个标志只建议在联调阶段启用,生产环境必须拿掉。

提示:关闭证书校验等于把 HTTPS 的防护让出去,生产环境不要这样做。如果你和对方网络之间有实体防火墙,业务量又不大,更值得调整的是服务器时间。

证书问题还有一个额外现象:服务端时间漂移也会导致证书校验失败,而且这种问题在客户端看起来像“服务器不可用”。排查时先对一下两台机器的时间,再考虑绕过校验。

5. 常见问题排查:中文乱码、命名空间、HTTP 状态码的五个现场案例

手写 SOAP 之后最大的优势是:所有问题都能从原始报文里找到原因,不需要去猜组件的内部行为。这章列五个我在现场遇到过的具体问题,每一条都按现象、原因、解决方法整理,基本都是可以复制到自己的排错清单里的。

5.1 服务端返回 500,faultstring 显示“无法处理请求”

现象:调用后 WinHTTP 返回 HTTP 500,响应体是一个 soap:Fault,faultstring 里写着 “Server was unable to process request” 或者更长的堆栈信息。第一次遇到会以为代码有问题,但客户端这边往往连断点都没触发。

原因:最常见的是 SOAPAction 没写对,或者请求 XML 里 element 的顺序、类型与 WSDL 不一致。SOAP 服务收到无法识别的操作或字段时,不会返回业务错误,而是抛出一个通用 Fault。

解决:先把发送的完整 XML 和 HTTP 头打印出来,对照 WSDL 里的操作定义逐项检查。重点看两个地方:一是 SOAPAction 的值是否和 binding 里一模一样,引号有没有加;二是<Add>下的子元素是否都按 WSDL 定义出现、顺序是否一致。如果是顺序问题,把 XML 里的元素顺序调整成 Schema 里定义的顺序即可。

5.2 selectSingleNode 返回空,明明响应里有字段

现象:调试时用文本编辑器打开响应 XML,里面明明有<AddResult>123</AddResult>,但代码里 selectSingleNode 怎么都找不到,返回的节点是空指针。

原因:响应使用了默认命名空间,比如<AddResult xmlns="http://tempuri.org/">。XPath 里的//AddResult表示的是“无命名空间”的 AddResult,而文档里的节点属于目标命名空间,两者不匹配,所以选不中。

解决:不要用固定路径去匹配,改写成//*[local-name()='AddResult'],或者显式给 XPath 注册命名空间前缀。我更推荐 local-name 这种方式,因为它不依赖服务端返回时用 tns、ns1 还是默认命名空间,少一层变量。如果字段在同一路径下出现多次,可以再加上位置判断,比如//*[local-name()='Item'][1]。

5.3 中文入参变成乱码,服务端日志显示问号

现象:客户端传入“张三”,服务端收到的却是“????”或者乱码。客户端本地看 Envelope 字符串是正常的,但发送出去后就不对了。

原因:请求 XML 在代码里是窄字符 std::string,里面存的是本机 ANSI 编码的字节,而 XML 声明和 HTTP 头都写了 utf-8。服务端严格按 UTF-8 解码,所以 ANSI 中文被解析成错误字符。

解决:所有 XML 在发送前走统一流程:宽字符字符串 -> WStringToUtf8 -> std::string。不要在这中间插入任何直接赋值,尤其不要用 CString 的 GetBuffer。返回时同理,先按 UTF-8 读回宽字符,再转成你界面层需要的字符串类型。这样编码问题只有一处,排查时不会到处问。

5.4 HTTPS 调用报 0x80072f8d,无法建立安全通道

现象:在能访问内网 https 服务的情况下,WinHttpSendRequest 返回 FALSE,GetLastError 是 0x80072f8d,或者错误信息是“无法建立与服务器的安全连接”。

原因:目标站点用了自签名证书、证书域名和访问地址不一致,或者证书已经过期。WinHTTP 默认安全策略比较严格,遇到这些情况会直接拒绝握手。

解决:先看目标证书和地址的关系。如果是开发联调,用上一章讲到的 WINHTTP_OPTION_SECURITY_FLAGS 临时忽略这三类问题;如果机器时间不对,先纠正时间。上线之前把这段安全绕过代码删掉,否则任何中间人都能伪装成你的服务端。

5.5 同一个程序在服务里跑,返回 403 或代理错误

现象:同样的代码在控制台程序里调用 WebService 正常,部署成 Windows 服务之后,访问内网地址返回 403,或者请求被转发到了一个不明代理,导致路径不对。

原因:服务进程的代理设置与会话用户不同,WinHTTP 使用 DEFAULT_PROXY 时会读取系统代理配置,服务账户可能落在不同的策略范围内。另一种情况是服务运行账户没有访问外网/内网的权限,403 是网络策略拦下来的。

解决:如果确定目标服务是内网直连地址,把 WinHttpOpen 的第一个参数从 WINHTTP_ACCESS_TYPE_DEFAULT_PROXY 改成 WINHTTP_ACCESS_TYPE_NO_PROXY,不让它走系统代理;如果必须走代理,再用第三个参数显式指定代理字符串。改成 NO_PROXY 后,还能顺带解决某些系统上代理脚本解析耗时导致超时的问题。

6. 进阶:把 SOAP 调用封装成 C++ 函数,验证响应不看眼力

如果你已经在手写 SOAP 的路线上跑通了第一个接口,下一步就是把它收进一个统一的入口,让业务代码只管传入参数、拿返回值,而不是每次复制一遍发送逻辑。

6.1 用统一入口封装请求与验证

我会把发送、响应、Fault 检查全部集中到一个结构体与函数里:

struct SoapResult { std::string rawXml; // 原始响应,排查时留着 std::wstring faultText; // 有 Fault 时填这里 bool ok = true; }; bool CallSoap(const std::wstring& url, const std::wstring& soapAction, const std::wstring& envelope, SoapResult& out) { std::string utf8Request = WStringToUtf8(envelope); std::string utf8Response; if (!SendSoapViaWinHttp(url, soapAction, utf8Request, utf8Response)) { out.ok = false; return false; } out.rawXml = utf8Response; // 先检查 Fault,再决定后续解析 std::wstring wsResp = Utf8ToWString(utf8Response); MSXML2::IXMLDOMDocument2Ptr spDoc; if (SUCCEEDED(spDoc.CreateInstance(__uuidof(MSXML2::DOMDocument60))) && spDoc->loadXML(_bstr_t(wsResp.c_str())) == VARIANT_TRUE) { MSXML2::IXMLDOMNodePtr fault = spDoc->selectSingleNode( _bstr_t(L"//*[local-name()='faultstring']")); if (fault) { out.faultText = fault->text; out.ok = false; return false; } } return true; }

这样业务层只需要拿到SoapResult,先判断ok,再判断faultText是否为空,最后才去提取业务字段。验证响应不再靠眼力翻屏幕,而是有明确逻辑分支。

6.2 把原始 XML 写进日志:排查时唯一的后悔药

我自己的最后一个习惯是:每次调用无论成败,都把请求 URL、请求 XML、响应 XML 按行追加到日志目录。调试期用 OutputDebugString 直接看,线上就写文件。遇到问题先翻原始报文,再谈猜测。这个习惯救过我很多次,尤其当服务端说“我这边没收到”的时候,请求日志一翻,是自己发的还是没发出去立刻清楚。

日志里不要做二次转码,保留最原始的接收字节。只要编码链路是封闭的,原始 XML 里的每一个字节都能还原当时场景,这就是排查时的后悔药。多年维护下来,我觉得这套 WinHTTP + MSXML 的手写方案虽然初看起来代码多,但它把所有变量都控制在代码里,反而比装一堆运行时组件来得踏实。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询