简介:这份资源面向需要在 VC++ 环境下集成远程接口的开发者,聚焦于通过 COM 组件与 SOAP 协议调用 Web Service 的完整实现思路。示例以短信发送服务为场景,演示了从创建 HttpConnector30 连接器、设置 EndPointURL 与 SoapAction,到用 SoapSerializer30 逐层构建 Envelope、Body 与参数元素,再经 EndMessage 发送并读取响应的全过程,对理解 SOAP 消息结构与 COM 接口协作方式很有参考价值。资源包内共 1 个 doc 文档,约 28KB,内容以可运行的 VC++ 代码片段与关键步骤注释为主,便于对照调试。目前已有 179 人学习,适合具备一定 C++ 与 COM 基础、希望快速掌握 Web Service 调用流程的中级开发者,可作为接口对接与排错时的实操参考。
1. 从一段“上古”VC++代码说起:为什么今天还要聊 SOAP 调用
翻到一段十多年前的 VC++ 代码,CWebservice_vcDlg::BeginSoap里用ISoapConnectorPtr、ISoapSerializerPtr手工拼 SOAP 信封,再通过HttpConnector30发出去。第一反应可能是“这玩意儿早该进博物馆了”,但现实是:不少制造业、医疗、政务的老系统里,Web Service 接口还在跑,而且只认 SOAP 1.1/1.2,REST 和 JSON 根本插不进去。这时候你手里如果只有 VS2019/2022 和一堆 C++ 代码,怎么把短信、工单、设备状态推过去?答案就是这套 COM 组件方案——SoapConnector+SoapSerializer+SoapReader。它不依赖 .NET 运行时,纯 Win32 就能跑,适合那些不能随便装框架的工控机、老服务器。这篇笔记就把这段代码拆开,讲清楚每个接口在干什么、参数怎么填、命名空间为什么容易翻车,以及我踩过的几个血泪坑。如果你手头正好有个?wsdl地址要接,这篇能让你少熬两个晚上。
2. 拆解 SOAP 调用链:从 HttpConnector30 到 RpcResult 的完整路径
2.1 三个 COM 对象的分工:连接器、序列化器、读取器
这套 API 的设计思路很清晰,就是“连接、写、读”三段式。ISoapConnectorPtr负责传输层,实际干活的是HttpConnector30,它把 SOAP 消息塞进 HTTP POST 的 body 里发出去。ISoapSerializerPtr负责把参数按 XML 规则拼成信封,ISoapReaderPtr负责把返回的 XML 解析成可访问的节点。三者通过InputStream和OutputStream两个流对象串起来:序列化器往连接器的输入流写,读取器从连接器的输出流读。
常见做法是先用CreateInstance(__uuidof(HttpConnector30))创建连接器,再设EndPointURL和SoapAction,然后Connect()。这里有个细节:Connect()只是建立底层 HTTP 连接,并不发数据,真正发送是在EndMessage()。所以如果你在Connect()之后直接读OutputStream,会拿到空字符串。
// 创建连接器并设置端点 SoapConnector.CreateInstance(__uuidof(HttpConnector30)); SoapConnector->Property["EndPointURL"] = "http://服务地址/cif/services/SMS?wsdl"; hr = SoapConnector->Connect(); if (FAILED(hr)) return ""; // 连接失败直接返回,别继续往下走参数说明:EndPointURL必须带?wsdl后缀吗?不一定。很多服务两种都认,但有些老服务只认带?wsdl的地址,不带就返回 404。我一般先按 WSDL 里<soap:address location="...">的值填,那个最准。Connect()返回FAILED(hr)时,别急着看e.Description(),先确认网络能不能通、端口有没有被防火墙拦。
2.2 命名空间与 SoapAction:两个最容易填错的地方
SoapAction这个 HTTP 头,很多服务端拿它做路由。代码里写的是szNameSpace + "send",其中szNameSpace是"http://211.95.160.38:80/cif/services/SMS/"。注意末尾那个斜杠,少了它,服务端可能返回500 Internal Server Error,而且错误信息里啥都不说,纯黑匣子。
更坑的是StartElement里的命名空间参数。代码里写的是"http://211.95.160.38:80/cif/services/SMS?wsdl",带了?wsdl。这其实是个常见误用——命名空间应该是 WSDL 里targetNamespace的值,通常不带?wsdl。但有些服务端实现比较宽松,带?wsdl也能过。如果你发现服务端返回SOAP-ENV:Client错误,说“无法处理请求”,八成是命名空间对不上。
// 设置 SoapAction,注意命名空间末尾斜杠 SoapConnector->Property["SoapAction"] = szNameSpace + _bstr_t("send"); // 开始消息体 SoapConnector->BeginMessage(); Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer->Init(_variant_t((IUnknown*)SoapConnector->InputStream)); Serializer->StartEnvelope(L"SOAP-ENV", L"", L"UTF-8"); Serializer->StartBody("");StartEnvelope的三个参数分别是前缀、命名空间、编码。前缀用SOAP-ENV是 SOAP 1.1 的惯例,编码用UTF-8能避免中文乱码。如果你要传中文短信内容,这里千万别改成GBK,服务端解析会出问题。
2.3 构造消息体:StartElement 的四个参数怎么填
StartElement有四个参数:元素名、命名空间、前缀、属性。代码里前缀写的是"NONE",这其实是个占位符,实际生成的 XML 里不会出现NONE:这种前缀。但如果你填了"NONE"又没在StartEnvelope里声明这个前缀,有些解析器会报错。稳妥的做法是前缀留空字符串"",让序列化器自动处理。
// 构造 send 元素及其子元素 Serializer->StartElement("send", "http://服务地址/cif/services/SMS?wsdl", "", ""); Serializer->StartElement("cmpcode", "http://服务地址/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("0166"); Serializer->EndElement(); Serializer->StartElement("phone", "http://服务地址/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("13258310354"); Serializer->EndElement(); Serializer->StartElement("content", "http://服务地址/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("你好"); Serializer->EndElement(); Serializer->EndElement(); // 结束 send Serializer->EndBody(); Serializer->EndEnvelope();每个StartElement必须对应一个EndElement,少一个就会生成畸形 XML,服务端直接拒收。WriteString写入的是元素文本内容,中文需要确保源文件保存为 UTF-8 带 BOM,否则CString转_bstr_t时可能丢字符。我一般会在WriteString之前用CT2A转一遍,确认编码没问题。
2.4 发送与读取:EndMessage 和 RpcResult 的配合
EndMessage()是真正把数据推出去的一步,返回FAILED(hr)说明 HTTP 层就失败了,常见原因是超时或连接被重置。如果返回成功,但Reader->RpcResult是空的,那问题出在服务端逻辑,不是传输层。
hr = SoapConnector->EndMessage(); if (FAILED(hr)) return ""; Reader.CreateInstance(__uuidof(SoapReader30)); Reader->Load(_variant_t((IUnknown*)SoapConnector->OutputStream), _T("")); CString str = CString((const char*)Reader->RpcResult->text); return str;Reader->Load的第二个参数是命名空间,留空表示不限制。RpcResult是服务端返回的sendResponse里的sendResult节点,具体名字取决于 WSDL 定义。如果服务端返回的是sendResponse而不是sendResult,你得用Reader->RpcResult之外的方式取,比如遍历Reader->Body的子节点。这一步没有万能写法,必须对着 WSDL 调。
3. 避坑与排查:命名空间、编码、超时和 COM 初始化
3.1 坑一:CoInitialize 没调用,CreateInstance 直接崩
现象:程序一运行到SoapConnector.CreateInstance就抛异常,或者返回E_NOINTERFACE。
原因:COM 库没初始化。MFC 对话框程序里,AfxOleInit()会帮你做这件事,但如果你是在控制台程序或线程里调用,必须手动CoInitialize(NULL)。
解决:在调用任何 COM 接口之前加CoInitializeEx(NULL, COINIT_APARTMENTTHREADED),程序退出时CoUninitialize()。注意每个线程都要单独初始化,别指望主线程初始化了子线程就能用。
3.2 坑二:命名空间带 ?wsdl 导致服务端返回 Client 错误
现象:EndMessage返回成功,但RpcResult里是SOAP-ENV:Client错误,提示“无法处理请求”或“方法未找到”。
原因:StartElement里的命名空间填了?wsdl后缀,而服务端targetNamespace不带。
解决:打开 WSDL 文件,找到<xsd:schema targetNamespace="...">,把那个值原样复制到StartElement的第二个参数。如果 WSDL 里确实带了?wsdl,那就保留,以 WSDL 为准。
3.3 坑三:中文内容变问号或乱码
现象:短信内容发出去是???或者乱码。
原因:CString在 Unicode 工程下是宽字符,WriteString接受BSTR,但序列化器内部按 UTF-8 编码。如果源文件编码和运行环境不一致,就会丢字符。
解决:确保源文件保存为 UTF-8 with BOM,并且在StartEnvelope里明确指定UTF-8。如果还是乱码,在WriteString之前用WideCharToMultiByte转成 UTF-8 的char*,再用_bstr_t包一层。
3.4 坑四:超时设置缺失,程序卡死
现象:调用EndMessage后程序无响应,等几十秒才返回超时错误。
原因:HttpConnector30默认超时时间很长,而且没有暴露明显的超时属性。
解决:在Connect()之前设置SoapConnector->Property["Timeout"] = 5000;(单位毫秒)。如果这个属性不生效,就在EndMessage外面套一个WaitForSingleObject或者用异步方式调用。我一般会设 5 秒,超过就重试或报错,别让界面卡死。
3.5 坑五:返回结果取错节点,RpcResult 为空
现象:Reader->RpcResult是NULL,但OutputStream里明明有 XML。
原因:服务端返回的响应结构不是标准的方法名Response/方法名Result,可能是Response/Result或者直接返回一个复杂类型。
解决:先把OutputStream的内容打印出来看,确认根节点和子节点名字。然后用Reader->Body->ChildNodes遍历,或者用Reader->GetNode按 XPath 取。别死磕RpcResult,它只对标准 RPC 风格有效。
4. 进阶技巧:用 SoapReader 遍历复杂响应与错误重试
4.1 遍历 Body 子节点,不依赖 RpcResult
当服务端返回的不是简单字符串,而是一个结构体或数组时,RpcResult就不够用了。这时候直接操作Reader->Body,拿到IXMLDOMNodePtr,再递归遍历。
// 遍历 Body 下所有子节点 IXMLDOMNodePtr pBody = Reader->Body; IXMLDOMNodeListPtr pList = pBody->GetchildNodes(); long len = 0; pList->get_length(&len); for (long i = 0; i < len; i++) { IXMLDOMNodePtr pNode = pList->Getitem(i); _bstr_t nodeName = pNode->GetnodeName(); _bstr_t nodeText = pNode->Gettext(); // 根据 nodeName 判断是哪个字段 if (strcmp((const char*)nodeName, "sendResult") == 0) { return CString((const char*)nodeText); } }这段代码的好处是不依赖固定的响应结构,只要知道字段名就能取。缺点是得手动处理命名空间前缀,nodeName可能带ns1:这种前缀,比较时要去掉。
4.2 失败重试与日志记录
网络调用不可能每次都成功,尤其是跨网段的老服务。我一般会包一层重试逻辑:失败后等 1 秒重试,最多 3 次。同时把每次的EndPointURL、SoapAction、请求 XML 和响应 XML 写到日志文件,方便事后排查。
int retry = 0; while (retry < 3) { hr = SoapConnector->EndMessage(); if (SUCCEEDED(hr)) break; Sleep(1000); retry++; } if (retry == 3) { // 写日志 CString log; log.Format(_T("SOAP调用失败,URL=%s,Action=%s"), url, action); WriteLog(log); return ""; }日志里别只写“失败”两个字,把请求和响应的原始 XML 都存下来。我吃过亏,有一次服务端返回的 XML 里有个隐藏的faultcode,但RpcResult是空的,后来翻日志才发现是服务端内部异常。
4.3 用 SoapSerializer 构造复杂嵌套结构
如果参数不是简单的字符串,而是嵌套的 XML 结构,StartElement可以多层嵌套。比如要传一个user对象,里面有name和age:
Serializer->StartElement("user", ns, "", ""); Serializer->StartElement("name", ns, "", ""); Serializer->WriteString("张三"); Serializer->EndElement(); Serializer->StartElement("age", ns, "", ""); Serializer->WriteString("30"); Serializer->EndElement(); Serializer->EndElement(); // 结束 user注意每层都要写命名空间,虽然有些服务端只认根元素的命名空间,但多写不会错。嵌套层数别超过 5 层,太深了服务端解析容易出问题。
4.4 验证方法:用 SoapUI 先跑通再写代码
在写 C++ 代码之前,我习惯先用 SoapUI 或 Postman 把接口调通。把 WSDL 地址贴进去,它会自动生成请求模板,填上参数发一次,看返回什么。确认无误后,再把 XML 结构映射到StartElement调用上。这样能避免在 C++ 里反复编译调试,效率高很多。
从那以后我每次接 SOAP 接口,都强制走一遍“SoapUI 验证 → 抓包看 XML → 写 C++ 代码 → 日志比对”的流程,再也没出现过命名空间填错这种低级问题。希望帮到你。
本文还有配套的精品资源,点击获取