简介:面向VC++开发者的Web Service调用示例资源,聚焦如何通过COM接口与SOAP协议完成远程服务交互,适合需要在传统VC++项目中集成Web Service的桌面应用开发者。资源包含1个doc文件,大小仅28KB,以文字说明和代码片段为载体,便于快速查阅核心流程。内容涉及SOAP连接器的创建、EndPointURL设定、消息序列化、参数封装与响应读取等关键步骤,并给出可直接参考的调用代码骨架。目前已有179人学习下载,对于希望理解SOAP调用原理并快速上手实践的程序员有一定参考价值。通过阅读该文档,读者可以了解从连接Web Service到构建SOAP消息再处理返回值的完整思路,减少自行摸索的时间成本。
1. VC 调用 WEBSERVICE:为什么说 SOAP 是 C++ 程序员最不想碰的接口类型
当有人把一个以 ?wsdl 结尾的地址丢到你桌上,说“就调个接口,很简单”,你多半就要开始踩 VC 调用 WEBSERVICE 的坑了。这个场景在传统企业项目里特别常见:老 MFC 程序要对接外部系统,对方只扔过来一份 WSDL 文件,剩下全得你自己想办法。最反直觉的一点是:调 WebService 最大的成本不是写 HTTP 请求,而是看懂对方的 WSDL,再把自己这边的 XML 命名空间、消息格式和传输细节对齐。这篇文章会按两条主流路线展开:一条是用 gSOAP 从 WSDL 生成 C 语言客户端代码;另一条是只靠系统自带的 WinHTTP 手拼 SOAP XML,不引任何第三方框架。两条路线都覆盖到常用参数、执行步骤和翻车点,适合正在给旧 C/S 程序排雷的工程人员。
2. 定方案前先看懂 WSDL:VC 侧有三条路,选错一条就要多加班两天
2.1 WSDL 是一份合同,但 VC 编译器不读合同
我先把 WSDL 当成一份合同来看。它用 XML 告诉调用方三件事:接口里有哪些方法(portType),每个方法的参数和返回类型长什么样(types/message),以及通过什么协议、什么地址去调用(binding/service)。对 C++ 来说,头疼点在于没有解析这份合同的编译器。微软和 Java 生态里都有从 WSDL 直接生成代理类的工具链,Java 那边常见做法是用 JAX-WS 的 wsimport 一条命令生成一堆类,后续调用就跟本地方法一样;VC 这边做同样的事,要么引入第三方代码生成器,要么自己把协议实现一遍。
很多同事拿到 WSDL 后直接双击打开,看到几百行 XML 就懵了。其实你只需先找三处:第一是根元素里的 targetNamespace,它是后面所有 namespace 对比的基准;第二是 types 段里你要调用的方法的字段名;第三是 binding 段里的 soapAction,这是 HTTP 头 SOAPAction 的来源。先找准这三处再选实现方案,后面至少能少改两版。
2.2 三条路怎么选:gSOAP、手写 XML 和 ATL Server 的取舍
VC 工程里能落地的常见选择其实只有三条路:gSOAP、手写 XML+WinHTTP、ATL Server 遗留方案。MSXML 算半个,它只负责解析,还是要自己发 HTTP。我把取舍整理成下面这张表,选型时直接对照。
| 方案 | 依赖 | 适用场景 | 要付的成本 |
|---|---|---|---|
| gSOAP | 引入生成源文件 | 接口多、需要长期维护 | 学习生成参数、理解生成代码、处理 GPL 许可 |
| 手写 XML + WinHTTP | Windows 系统自带 | 一次对接、跑通即好 | 自己拼报文、自己解析响应 |
| ATL Server | 老工具链 | 基本废弃 | 维护成本太高,新工具链不兼容 |
| MSXML | Windows 系统自带 | 只负责解析 | 不能解决发送问题,仍需搭配 HTTP 库 |
gSOAP 是双许可模型,开源版是 GPLv2,商业闭源项目要用就得买商业授权。这一点在选型时经常被忽略,等到代码评审才被法务拦下来。手写方案的所有代码都属于你自己,没有许可证问题,这也是它最受 IT 审计欢迎的一点。ATL Server 从 VS2005 之后就基本停止维护,它的 SOAP 支持依赖老版本 ATL 组件,新装的环境很难跑起来,不建议再碰。
2.3 我一般怎么定:接口数量决定路线
如果项目时间只有两三天、接口不超过三四个,手工方案其实是更稳的选择,因为你不需要熟悉 soapcpp2 那套生成规则,调一个就是调一个。如果系统将来要对接多个服务方,或者对方服务里带了复杂的安全头、附件,那 gSOAP 的序列化框架能省掉很大一块工时。还有一点要提醒:Java 领域调 WebService 有现成的 JAX-WS,生成代码后基本不用管 XML,这个轻松程度在 VC 世界里不存在,所以不要拿“java 调用 webservice 接口”的顺手程度来要求自己的交付时间,规划工期时多留一天给协议对齐会从容很多。
3. gSOAP 从 WSDL 生成 C 客户端代码:命令、参数与 VC 工程挂接
3.1 准备 gSOAP 工具链:wsdl2h 和 soapcpp2 各干一件事
选定 gSOAP 后,第一件事不是写代码,而是把工具链准备好。gSOAP 包含两个命令行工具:wsdl2h 把 WSDL 翻译成 C/C++ 头文件;soapcpp2 把头文件翻译成客户端骨架代码。到 gSOAP 官方站点(Genivia)下载当前版本,解压后 bin 目录里就是 wsdl2h.exe 和 soapcpp2.exe。把 bin 目录加进 PATH,或者每次用全路径,我习惯放到工程目录下的 tools 子目录里,这样换机器不至于找不到。
第一次使用会碰到的玄学问题是:wsdl2h 在解析某些新版本 WCF 导出的 WSDL 时可能直接报错或者生成空头文件,优先换更新版 gSOAP,而不是去手工改 WSDL。另外确认一下你们两个工具能正常运行,在命令行里直接敲 wsdl2h 会打印帮助信息,如果提示缺少 DLL,多半是系统缺 Visual C++ 运行库,装上就行。
3.2 用 wsdl2h 生成头文件:先打开检查再往下走
把 WSDL 地址传给 wsdl2h,命令如下:
wsdl2h -c -o calc.h http://192.168.1.10:8080/CalcService/Calc.wsdl-c 表示生成 C 语言代码而不是 C++,-o 指定输出头文件名。如果你下载的是 WSDL 文件而不是地址,第一个参数直接写本地文件路径也可以。命令执行成功后,当前目录会多一个 calc.h。
这里有一个强烈建议:生成后马上打开 calc.h 看第一段注释。工具会在头文件顶部提醒你检查生成结果是否完整。WSDL 里如果还引用了外部 XSD Schema,wsdl2h 有时会生成不完整的类型引用,最典型的现象是头文件里出现未定义的结构体或者 include 了一个不存在的路径。这时候要把缺失的 Schema 一并下载到本地,再用 -I 参数指定搜索路径重新生成。
3.3 用 soapcpp2 生成客户端骨架:产物不是只有一个 .c 文件
确认 calc.h 没问题后,执行第二条命令:
soapcpp2 -c -C -L -I C:\gsoap-2.8\gsoap\import calc.h这里每个参数都有明确用途。-c 表示生成 C 语言代码,和 wsdl2h 保持一致;-C 表示只生成客户端代码,不生成服务端;-L 禁止生成服务端相关的 soapServer 文件,避免工程里混入不需要的代码;-I 指向 gSOAP 自带的 import 目录,解析 XSD 内置类型时会用到。
命令跑完,目录里会多出一批文件。我第一次用时看花眼了,以为只要把 soapClient.c 加进工程就能跑,结果链接全报错。实际要关注的产物是下面这几个:
| 生成文件 | 作用 | 工程里怎么处理 |
|---|---|---|
| soapStub.h | 定义所有类型和函数原型 | 在代码里 include |
| soapH.h | gSOAP 引擎声明 | 在代码里 include |
| soapClient.c | 客户端调用逻辑 | 加入工程编译 |
| soapC.c | XML 序列化/反序列化实现 | 加入工程编译 |
| ns.nsmap | 命名空间前缀映射 | 在代码里用 #import 引入 |
3.4 写一个最小客户端:soap_call_XX 的调用规则
生成代码后,下一步是看 soapStub.h 里你要调用的那个函数的原型。以 WSDL 里有个 Add 方法为例,生成的函数签名大概是:
int cal__Add(struct soap *soap, const char *endpoint, double a, double b, double *result);函数名的 cal__Add 是命名空间前缀加方法名的组合,双下划线是 gSOAP 的规则。用一段完整代码说明调用流程:
#include "soapH.h" #include "ns.nsmap" int main(void) { struct soap *sp = soap_new(); if (!sp) { fprintf(stderr, "soap_new failed\n"); return 1; } soap_set_connect_timeout(sp, 5); soap_set_send_timeout(sp, 10); soap_set_recv_timeout(sp, 10); double a = 2.5, b = 4.5, result = 0.0; int ret = cal__Add(sp, "http://192.168.1.10:8080/CalcService/Calc.asmx", a, b, &result); if (ret == SOAP_OK) { printf("result = %f\n", result); } else { soap_print_fault(sp, stderr); } soap_destroy(sp); soap_end(sp); soap_free(sp); return 0; }soap_new 创建调用上下文,一个线程里应该只有一个 soap 实例;三个超时函数的单位都是秒,connect 超时设 5 秒、收发包超时设 10 秒是比较保守的起步值。endpoint 传 NULL 时会用 WSDL 里写的地址,但建议显式传服务地址,因为生产环境的地址经常和 WSDL 里的不一致。函数返回值是 SOAP_OK 时表示消息交换成功,注意只是交换成功,业务上有没有报错还要看响应里有没有 Fault 节点,这个我会在第 6 章详细说。
3.5 把生成的代码编进 VC 工程:三处设置不能漏
生成代码要编进工程,总共要动三个地方。第一,把 soapClient.c、soapC.c 和 gSOAP 包里的 stdsoap2.c 添加进工程;第二,把生成目录和 gSOAP 的 include 目录加进附加包含目录;第三,在链接器输入里加上 ws2_32.lib,因为 gSOAP 底层用的是 socket。如果 gSOAP 包里还带了预编译的静态库,那直接加库文件就可以,不用再把 stdsoap2.c 加进来。
有一点容易踩:如果工程字符集是 Unicode,返回的 char * 字符串需要用 MultiByteToWideChar 转成宽字符再显示,直接 printf 会打出乱码。另外 soapcpp2 生成的代码默认没有启用 HTTP 压缩和 TLS,需要对应功能时要去修改 stdsoap2.h 里的宏定义,这个后面第 5 章会展开。
4. 不引框架也能调:用 WinHTTP 手拼 SOAP Envelope 的完整流程
4.1 手写 SOAP Envelope 的骨架:Envelope、Body 和 namespace 缺一不可
如果公司 IT 审核不让你放第三方生成代码,或者你只想快速交付一次,手工方案是另一条可靠的路。它的核心只有三步:拼 XML、用 WinHTTP 发送、解析返回。WinHTTP 是 Windows 系统组件,从 XP 到 Win11 都带,调用方不需要装任何运行时。
拼 SOAP 请求前,先明确一件事:SOAP 消息不是随便一段 XML 就能通过验证的,必须按规范的 Envelope 结构来。一个标准请求由 Envelope 根节点、Body 子节点和业务操作方法节点组成,命名空间必须和 WSDL 的 targetNamespace 完全一致。下面是一个典型的 Add 请求构造函数:
std::string BuildAddRequest(double a, double b) { std::string xml; xml += "<?xml version=\"1.0\" encoding=\"utf-8\"?>"; xml += "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" "; xml += "xmlns:cal=\"http://tempuri.org/\">"; xml += "<soap:Body>"; xml += "<cal:Add>"; xml += "<cal:a>" + std::to_string(a) + "</cal:a>"; xml += "<cal:b>" + std::to_string(b) + "</cal:b>"; xml += "</cal:Add>"; xml += "</soap:Body>"; xml += "</soap:Envelope>"; return xml; }这里 xmlns:cal 的值要从 WSDL 的 targetNamespace 原样抄过来,tempuri.org 是 .NET 工程的默认命名空间,Java 服务通常是一长串反写的公司域名。节点名 Add、a、b 要和 WSDL 里定义的操作名和参数名一致,大小写错一个字符都会服务端报错。拼参数值时要小心特殊字符,如果入参是字符串,必须对 & < > 等做 XML 转义,否则请求到达服务端时会直接解析失败。
4.2 用 WinHTTP 发请求:五步调用序列与超时设置
XML 拼好之后,用 WinHTTP 把它发出去。WinHTTP 的调用序列是固定的五步:WinHttpOpen 建会话、WinHttpConnect 建连接、WinHttpOpenRequest 建请求、WinHttpSendRequest 发送、WinHttpReceiveResponse 接收响应。下面是核心代码:
#include <windows.h> #include <winhttp.h> #include <string> #pragma comment(lib, "winhttp.lib") bool SendSoapRequest(const std::string& xmlBody, const std::wstring& host, int port, const std::wstring& path, std::string& response) { HINTERNET hSession = WinHttpOpen(L"VCSOAP-Demo", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (!hSession) return false; HINTERNET hConnect = WinHttpConnect(hSession, host.c_str(), port, 0); if (!hConnect) { WinHttpCloseHandle(hSession); return false; } HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", path.c_str(), L"HTTP/1.1", WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, 0); if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return false; } std::wstring headers = L"Content-Type: text/xml; charset=utf-8\r\n"; headers += L"SOAPAction: \"http://tempuri.org/Add\""; WinHttpAddRequestHeaders(hRequest, headers.c_str(), (DWORD)headers.size(), WINHTTP_ADDREQ_FLAG_REPLACE | WINHTTP_ADDREQ_FLAG_ADD); BOOL ok = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, (LPVOID)xmlBody.c_str(), (DWORD)xmlBody.size(), (DWORD)xmlBody.size(), 0); if (ok) ok = WinHttpReceiveResponse(hRequest, NULL); if (ok) { char buf[4096]; DWORD dwSize = 0; while (ok && WinHttpReadData(hRequest, buf, sizeof(buf) - 1, &dwSize) && dwSize > 0) { buf[dwSize] = '\0'; response += buf; dwSize = 0; } } WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return ok; }Content-Type 里的 charset=utf-8 不能漏,服务端拿这个头决定用哪种编码解析 body;SOAPAction 头是 WCF 和很多 Java 服务做分发时的重要依据,值就是 WSDL binding 段里 soapAction 的完整字符串,而且不能去掉引号。WinHttpSendRequest 的第三、四个参数分别传 body 长度和实际字节数,函数会自动生成 Content-Length,不要在 headers 里再手写一遍。接收时循环读直到 dwSize 为 0,因为响应可能分多个 TCP 包到达。
4.3 解析 SOAP 响应:用 MSXML 按节点名取值,不赌正则
响应是一段 SOAP XML,服务成功时返回目标节点的值,失败时返回 Fault 节点。解析首选 MSXML,它是 Windows 内置的 XML DOM 实现。关键代码如下:
#include <msxml6.h> #include <comdef.h> #pragma comment(lib, "msxml6.lib") bool ParseResult(const std::string& soapResponse, std::string& outValue) { CoInitialize(NULL); IXMLDOMDocument* pDoc = NULL; HRESULT hr = CoCreateInstance(CLSID_DOMDocument60, NULL, CLSCTX_INPROC_SERVER, IID_IXMLDOMDocument, (void**)&pDoc); if (FAILED(hr) || !pDoc) return false; std::wstring wResp = std::wstring(soapResponse.begin(), soapResponse.end()); VARIANT_BOOL bSuccess = VARIANT_FALSE; pDoc->loadXML(_bstr_t(soapResponse.c_str()), &bSuccess); if (bSuccess) { IXMLDOMElement* pRoot = NULL; pDoc->get_documentElement(&pRoot); if (pRoot) { IXMLDOMNodeList* pList = NULL; pRoot->getElementsByTagName(L"AddResult", &pList); if (pList) { IXMLDOMNode* pNode = NULL; pList->get_item(0, &pNode); if (pNode) { BSTR bstrText = NULL; pNode->get_text(&bstrText); if (bstrText) { outValue = _bstr_t(bstrText); SysFreeString(bstrText); } pNode->Release(); } pList->Release(); } pRoot->Release(); } } pDoc->Release(); CoUninitialize(); return !outValue.empty(); }getElementsByTagName 直接用节点名定位,不需要处理命名空间绑定,比 selectSingleNode 写 XPath 更省事。要注意的是,这个函数没有处理命名空间前缀,如果响应里 AddResult 节点带前缀,名字会变成 cal:AddResult,getElementsByTagName 需要用完整名字。简化处理是外层先取 Body 节点,再在 Body 下按 local-name 过滤,这样最稳。MSXML 的 COM 对象要在主线程初始化,CoInitialize 和 CoUninitialize 必须成对出现。
5. 注意这 5 类常见问题:从超时、UTF-8 到 TLS,比调用返回码更值得排查
5.1 客户端卡住不动:超时参数没设,等了一个小时都不报错
现象:gSOAP 或 WinHTTP 都调通了,但是服务端处理慢或者网络有抖动时,客户端会一直卡住,没有任何超时错误。
原因:gSOAP 的 soap 上下文默认没有设置接收超时,服务端一直不返回它会一直等;WinHTTP 的默认超时比较长,在企业代理环境下表现更明显。大部分工程只设置了连接超时,接收超时往往被忽略。
解决:gSOAP 里在 soap_new 之后马上调用 soap_set_connect_timeout 和 soap_set_recv_timeout,分别设 5 秒和 30 秒。WinHTTP 用 WinHttpSetTimeouts 设置,单位是毫秒,四个参数分别是解析超时、连接超时、发送超时、接收超时。我一般设 5000、5000、10000、30000,这套参数经过一些移动网络环境的考验,够用。
5.2 中文参数变成乱码:请求体要求 UTF-8,工程字符串是 GBK
现象:接口里带中文参数,服务端接收后显示为“???”,或者直接报 XML 解析错误。
原因:VC 工程如果是多字节字符集,字符串在内存里是 GBK 编码,直接拼进 XML 发出去就是 GBK 字节流,而 SOAP 规范和服务端都默认按 UTF-8 解码。WinHTTP 头里写了 charset=utf-8 也没用,因为 body 的实际编码就是错的。
解决:拼接 XML 之前先把字符串从 GBK 转成 UTF-8。这段转换代码在每个工程里基本通用:
std::string GbkToUtf8(const std::string& gbk) { int wLen = MultiByteToWideChar(CP_ACP, 0, gbk.c_str(), (int)gbk.size(), NULL, 0); std::wstring wStr(wLen, L'\0'); MultiByteToWideChar(CP_ACP, 0, gbk.c_str(), (int)gbk.size(), &wStr[0], wLen); int uLen = WideCharToMultiByte(CP_UTF8, 0, wStr.c_str(), wLen, NULL, 0, NULL, NULL); std::string utf8(uLen, '\0'); WideCharToMultiByte(CP_UTF8, 0, wStr.c_str(), wLen, &utf8[0], uLen, NULL, NULL); return utf8; }注意这里的输入长度用 gbk.size() 而不是 -1,避免把字符串结尾的 \0 也转进去导致输出串里多一个终止符。服务端返回的中文也是 UTF-8,要显示到界面再用反向的 Utf8ToGbk 转回来。
5.3 HTTPS 服务调不通:自签证书让 TLS 握手直接失败
现象:服务地址从 http 换成 https 后,WinHTTP 调用返回 12037 类似错误,gSOAP 那边则在握手阶段就打印 SSL 错误。
原因:内网部署的 WebService 很多用自签证书,系统不信任该证书颁发者,TLS 客户端在校验证书链时直接中断。
解决:测试环境可以用 WinHttpSetOption 设置 WINHTTP_OPTION_SECURITY_FLAGS,忽略未知 CA 和证书名称不匹配的校验。gSOAP 里要做 TLS 初始化,在 soap_new 之后调用 soap_ssl_init,并给 soap_ssl_client_context 传入证书相关参数。生产环境不能这样做,要把服务器证书导入到 Windows 受信任的根证书存储区,或者用官方 CA 签发的证书。这里要特别提醒:如果公司网络有统一 SSL 出口,抓包看到的证书链可能和源站不一致,联调时必须优先确认服务端真实证书。
5.4 返回 SOAP Fault:namespace 写错十次有八次是这个原因
现象:HTTP 状态码是 200,但响应体里是 Fault 节点,提示 “Method not allowed” 或 “no such operation”,或者直接说找不到请求的操作。
原因:SOAPAction 头没有带、带错了值,或者 Body 里的节点命名空间和目标命名空间不一致。最常见的是把 WSDL 里的 targetNamespace 抄漏了一个斜杠或者大小写不对。另一个高频问题是把命名空间前缀自定义成了别的字符串,但 XML 里的节点前缀和实际 namespace 值不匹配时,服务端按 namespace 解析就会失败。
解决:不要凭记忆写命名空间,直接从 WSDL 的 targetNamespace 复制。调试时按第 6 章的日志方案把请求原样存下来,用文本对比工具和你抓包拿到的正常请求做 diff,一眼就能看到哪里不一样。Java 服务对大小写比 .NET 更敏感,字段名 a 和 A 是两个完全不同的节点。
5.5 gSOAP 程序退出时崩溃:soap_destroy 和 soap_end 的执行顺序错了
现象:客户端能正常拿到结果,但程序退出时崩溃,或者循环调用第二次时返回的字段值变成初始值。
原因:soap_destroy 和 soap_end 的顺序写反了,或者根本没有调用 soap_destroy。soap_destroy 负责释放反序列化过程中创建的 C 对象实例,soap_end 负责释放 gSOAP 内部临时缓冲,顺序反了会导致二次释放或者释放了还在使用的内存。
解决:固定按 soap_destroy、soap_end、soap_free 的顺序收尾。如果要在循环里多次调用接口,不要每次 soap_new,而是在循环外用 soap_new 创建上下文,每次迭代开始调用 soap_begin,结束调用 soap_destroy 和 soap_end,最后统一 soap_free。这个模式下所有临时对象都会正确回收。
6. 学我加一处落盘日志:让 SOAP 请求和响应从黑匣子变成可回放文本
接口联调最怕的其实是黑匣子。请求发出去了,返回一个看不懂的 XML Fault,代码看哪里都觉得没问题,但就是不对。我的习惯是任何调用方案都先把报文存成文件,再谈协议、再谈业务逻辑。这个习惯帮我减少过至少两天无头绪的排查时间。
以手写方案为例,发送前把请求 XML 写盘,收到响应后也写盘:
static void dump_soap_xml(const char* tag, const char* xml) { FILE* fp = fopen("soap_dump.txt", "ab"); if (!fp) return; fprintf(fp, "\n======== %s ========\n%s\n", tag, xml ? xml : "(null)"); fclose(fp); }调用前 dump_soap_xml("REQUEST", request.c_str()),拿到响应后 dump_soap_xml("RESPONSE", response.c_str())。gSOAP 如果不想自己写,它也有 soap_set_recv_log 和 soap_set_send_log 可以落盘,不过那个日志格式会把 HTTP 头和消息体混在一起,我更喜欢自制一个纯 XML 的输出文件。
验证顺序我一般走四条:先把请求 XML 保存下来,然后用文本编辑器检查命名空间和节点名,再用 curl 之类的工具把这段 XML 原样重放一次,确认服务端行为,最后才去改代码。这四步走完,大多数问题都能定位到具体节点,而不是在代码里靠猜。我在这种接口对接上栽过最深的一个跟头,是没打日志直接按猜的改命名空间,改了三版都没通,后来把收到的 SOAP Fault 存下来一看,是服务端大小写敏感。所以希望你动手之前,先给这套调用留一个能回放的出口,希望帮到你。
本文还有配套的精品资源,点击获取