简介:这份资源面向需要在 VC++ 环境下集成远程服务的开发者,聚焦于通过 COM 接口与 SOAP 协议调用 Web Service 这一常见但门槛较高的场景。内容围绕 ISoapConnector、ISoapSerializer、ISoapReader 等核心组件展开,完整演示了创建 HttpConnector30 连接器、设置 EndPointURL 与 SoapAction、构建 SOAP 信封与消息体、传递 cmpcode、phone、content 等参数并读取 RpcResult 响应的全过程,对理解 SOAP 消息结构与 COM 组件协作方式很有帮助。资源包内共 1 个 doc 文档,约 28KB,以代码示例与关键步骤说明为主,便于对照调试。目前已有 179 人学习,适合具备一定 C++ 基础、希望掌握 VC++ 调用 Web Service 排错思路与实现细节的读者参考。
1. 从一段“上古”VC代码说起:为什么今天还要看它
翻出一段十几年前的 VC++ 代码,里面用 COM 组件拼 SOAP 报文去调短信接口,HttpConnector30、SoapSerializer30这些名字现在看像出土文物。但如果你手头正好有个老 MFC 项目要对接 WebService,或者维护一套跑在工控机上的 C++ 程序,这段代码就是能直接抄的作业。它不依赖 gSOAP、不依赖 .NET,纯靠 Windows 自带的 MS SOAP Toolkit 3.0 就能把请求发出去、把响应读回来。本文围绕这份 VC 调用 WebService 的完整示例,把连接器创建、报文序列化、参数传递、响应解析整条链路拆开讲,顺带说说怎么把它改造成能复用的封装类。适合正在维护遗留 C++ 项目、需要快速打通 SOAP 接口的开发者。
2. 环境准备与 COM 组件初始化:让 VC 认识 SOAP
2.1 安装 MS SOAP Toolkit 3.0 并引入类型库
这套代码的核心是微软的 SOAP Toolkit,它把 SOAP 通信封装成几个 COM 接口。第一步是确认开发机上装了MSSOAP3.dll,通常路径在C:\Program Files\Common Files\MSSoap\Binaries\下。装完之后,在 VC 项目里通过#import指令引入类型库,让编译器自动生成包装类。
// 在 stdafx.h 或主对话框头文件中引入 SOAP 类型库 // 注意路径要指向实际安装位置,不同系统可能略有差异 #import "C:\Program Files\Common Files\MSSoap\Binaries\MSSOAP30.dll" \ no_namespace \ named_guids \ rename("EOF", "EndOfFile")这里几个参数值得说明。no_namespace表示不把生成的类型放进额外命名空间,直接用全局名,方便老代码调用。named_guids让编译器用__uuidof能识别的 GUID 常量,后面CreateInstance就靠它。rename("EOF", "EndOfFile")是血泪经验——SOAP 库里有个EOF枚举,跟标准库的EOF宏冲突,不改名编译直接翻车。引入之后,ISoapConnectorPtr、ISoapSerializerPtr、ISoapReaderPtr这些智能指针类型就能用了。
2.2 创建连接器与序列化器实例
代码里SoapConnector.CreateInstance(__uuidof(HttpConnector30))这一行,实际做的是通过 COM 的类工厂创建 HTTP 传输连接器。HttpConnector30是 Toolkit 提供的默认实现,走 HTTP 协议。同理SoapSerializer30负责把 C++ 数据序列化成 XML 格式的 SOAP 报文,SoapReader30负责把响应 XML 反序列化回可读对象。
// 三个核心 COM 对象的智能指针声明 ISoapConnectorPtr SoapConnector; // 负责网络传输 ISoapSerializerPtr Serializer; // 负责构造请求报文 ISoapReaderPtr Reader; // 负责解析响应报文 // 创建实例,__uuidof 取的是类型库中注册的 CLSID SoapConnector.CreateInstance(__uuidof(HttpConnector30)); Serializer.CreateInstance(__uuidof(SoapSerializer30)); Reader.CreateInstance(__uuidof(SoapReader30));常见做法是把这三个对象的创建放在一个初始化函数里,失败就返回错误码。注意CreateInstance失败会抛_com_error异常,所以外层必须包 try-catch。我一般会在 catch 里把e.Description()转成 CString 记到日志,不然出问题只能看到一句“未知错误”,排查起来像黑匣子。
2.3 设置终结点与 SOAPAction
连接之前要告诉连接器两件事:服务地址和要调用的方法。EndPointURL是 WebService 的完整地址,SoapAction是 SOAP 1.1 规范里用来标识操作的 HTTP 头。
// 设置服务终结点,注意这里带上了 ?wsdl 后缀 SoapConnector->Property["EndPointURL"] = "http://211.95.160.38:80/cif/services/SMS?wsdl"; // 设置 SOAPAction,格式通常是命名空间 + 方法名 char *szNameSpace = "http://211.95.160.38:80/cif/services/SMS/"; SoapConnector->Property["SoapAction"] = szNameSpace + _bstr_t("send");这里有个容易踩的坑:EndPointURL带?wsdl在部分服务端能正常工作,但严格来说 WSDL 地址和实际调用地址可能不同。如果服务端返回 404 或 500,先把?wsdl去掉试试。SoapAction的值必须和服务端 WSDL 里定义的一模一样,多一个斜杠少一个斜杠都会导致“方法未找到”。参数szNameSpace末尾的斜杠是命名空间的一部分,不能省。
3. 核心链路拆解:从 BeginMessage 到 EndMessage 的完整报文构造
3.1 连接建立与消息初始化
Connect()方法真正发起 TCP 连接。返回HRESULT,用FAILED宏判断。连接成功后调用BeginMessage()开启一个消息上下文,之后所有序列化操作都写入这个上下文。
// 建立连接,失败直接返回空串 HRESULT hr = SoapConnector->Connect(); if (FAILED(hr)) return ""; // 开启消息,准备写入 SOAP 信封 SoapConnector->BeginMessage(); // 把序列化器绑定到连接器的输入流 Serializer->Init(_variant_t((IUnknown*)SoapConnector->InputStream));Serializer->Init这一步是把序列化器的输出目标指向连接器的输入流。_variant_t((IUnknown*)...)这个强制转换是 Toolkit 的接口约定,不这么写编译不过。参数说明:InputStream是连接器提供的只写流,序列化器往里写,连接器负责在EndMessage时把流内容发出去。
3.2 构造 SOAP 信封与消息体
SOAP 报文本质是一段特定结构的 XML。StartEnvelope写<SOAP-ENV:Envelope>根节点,StartBody写<SOAP-ENV:Body>,然后逐个StartElement写方法名和参数。
// 写 SOAP 信封,指定编码为 UTF-8 Serializer->StartEnvelope(L"SOAP-ENV", L"", L"UTF-8"); Serializer->StartBody(""); // 写方法名 send,命名空间用完整的服务地址 Serializer->StartElement("send", "http://211.95.160.38:80/cif/services/SMS?wsdl", "", ""); // 写第一个参数 cmpcode Serializer->StartElement("cmpcode", "http://211.95.160.38:80/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("0166"); Serializer->EndElement(); // 写第二个参数 phone Serializer->StartElement("phone", "http://211.95.160.38:80/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("13258310354"); Serializer->EndElement(); // 写第三个参数 content Serializer->StartElement("content", "http://211.95.160.38:80/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString("你好"); Serializer->EndElement(); // 写第四个参数 receivedate,传空值 Serializer->StartElement("receivedate", "http://211.95.160.38:80/cif/services/SMS?wsdl", "NONE", ""); Serializer->WriteString(""); Serializer->EndElement(); // 闭合方法元素、Body、Envelope Serializer->EndElement(); Serializer->EndBody(); Serializer->EndEnvelope();StartElement的四个参数分别是:元素名、命名空间 URI、命名空间前缀、属性。这里前缀传"NONE"是 Toolkit 的写法,表示不使用前缀。每个StartElement必须对应一个EndElement,漏掉一个整个 XML 结构就废了,服务端解析直接报错。WriteString写入的是元素文本内容,中文内容在 UTF-8 编码下能正常传输,但要注意源文件保存为 UTF-8 带 BOM 格式,否则中文可能变乱码。
3.3 发送请求与读取响应
报文构造完后,EndMessage()触发实际发送。这一步是同步阻塞的,网络慢的时候界面会卡住,后面讲改造时会说怎么处理。
// 真正发送消息 hr = SoapConnector->EndMessage(); if (FAILED(hr)) return ""; // 创建 Reader 并绑定到连接器的输出流 Reader->Load(_variant_t((IUnknown*)SoapConnector->OutputStream), _T("")); // 读取 RPC 结果,RpcResult 是 Toolkit 对返回值的封装 CString strResult = CString((const char*)Reader->RpcResult->text); return strResult;Reader->Load把响应流加载进来,RpcResult属性直接取到方法返回值的文本。如果服务端返回的是复杂结构,RpcResult可能不够用,需要用Reader->RpcResult->ChildNodes遍历子节点。参数说明:OutputStream是连接器提供的只读流,Load的第二个参数是编码提示,传空字符串让 Toolkit 自动识别。
3.4 异常捕获与错误信息提取
整个流程包在 try-catch 里,捕获_com_error。这个异常类有个Description()方法返回 BSTR 格式的错误描述,转成 CString 就能用。
catch (_com_error e) { // e.Description() 返回 BSTR,先转 char* 再转 CString CString errMsg = (CString)(char*)e.Description(); // 实际项目中这里应该写日志,而不是直接返回 return errMsg; }注意(char*)e.Description()这个转换在 Unicode 工程下会丢字符,稳妥做法是用_bstr_t中转:CString errMsg = (LPCSTR)_bstr_t(e.Description());。另外_com_error还有Error()方法返回 HRESULT,ErrorMessage()返回系统错误描述,两个结合起来看更容易定位问题。
4. 避坑与常见问题排查:那些年我们调 SOAP 踩过的雷
4.1 连接失败返回 0x80070005 拒绝访问
现象是Connect()返回失败,HRESULT 是0x80070005。原因通常是目标服务要求身份验证,而代码里没设置凭据。解决方式是在连接前设置SoapConnector->Property["Username"]和["Password"],或者用ConnectorProperty设置AuthUser、AuthPassword。如果服务端用的是 NTLM 认证,还要加SoapConnector->Property["WindowsAuthentication"] = "true"。
4.2 中文参数变成问号或乱码
现象是发送的中文内容在服务端收到后变成???。原因有两个:一是源文件编码不是 UTF-8,二是StartEnvelope里指定的编码和实际写入的字节不一致。解决方法是把.cpp文件用 UTF-8 带 BOM 保存,同时确认StartEnvelope第三个参数是L"UTF-8"。如果服务端只认 GBK,那就把编码改成L"GBK",但这样通用性差,常见做法还是统一走 UTF-8。
4.3 SOAPAction 不匹配导致方法未找到
现象是服务端返回 SOAP Fault,提示Method 'send' not found或SOAPAction mismatch。原因是SoapAction的值和 WSDL 里定义的不一致。解决方法是打开 WSDL 文件,搜索soap:operation标签,把soapAction属性的值原样复制过来。注意有些服务端的 SOAPAction 带双引号,有些带命名空间前缀,必须完全一致。
4.4 调用后界面卡死无响应
现象是点击按钮后整个窗口卡住,直到超时或返回。原因是EndMessage()是同步阻塞调用,网络延迟直接反映到 UI 线程。解决方式是把整个 SOAP 调用放到工作线程里,用AfxBeginThread或std::thread起一个后台线程,完成后通过PostMessage通知 UI 更新。如果不想改线程模型,至少设置超时:SoapConnector->Property["Timeout"] = "5000",单位毫秒。
4.5 多次调用后内存持续增长
现象是反复调用接口后进程内存只增不减。原因是 COM 对象没有正确释放。ISoapConnectorPtr这些智能指针在析构时会自动Release,但如果把CreateInstance放在循环里反复创建而不释放,就会泄漏。解决方法是把连接器、序列化器、读取器作为成员变量复用,每次调用只重新BeginMessage和EndMessage,不要反复创建销毁。
5. 二次开发改造:把示例代码封装成可复用的 SOAP 客户端类
5.1 封装思路与类结构设计
原始代码把连接、序列化、发送、解析全塞在一个函数里,参数写死,没法复用。改造目标是抽出一个CSoapClient类,对外暴露Invoke(method, params)接口,内部管理 COM 对象生命周期。类结构大致如下:
class CSoapClient { public: CSoapClient(); ~CSoapClient(); // 初始化:设置服务地址和命名空间 bool Init(const CString& strEndPoint, const CString& strNameSpace); // 调用方法:method 是方法名,params 是参数名值对 CString Invoke(const CString& strMethod, const std::map<CString, CString>& params); private: ISoapConnectorPtr m_pConnector; ISoapSerializerPtr m_pSerializer; ISoapReaderPtr m_pReader; CString m_strNameSpace; bool m_bInitialized; };Init里做一次性的连接器创建和终结点设置,Invoke里只做消息构造和发送。这样多次调用时 COM 对象复用,避免反复创建的开销和泄漏风险。
5.2 参数动态构造与序列化循环
原始代码里每个参数都手写一遍StartElement/WriteString/EndElement,参数一多代码就臃肿。用std::map存参数后,可以循环处理:
CString CSoapClient::Invoke(const CString& strMethod, const std::map<CString, CString>& params) { if (!m_bInitialized) return _T(""); try { // 设置 SOAPAction m_pConnector->Property["SoapAction"] = (_bstr_t)(m_strNameSpace + strMethod); // 开启消息 m_pConnector->BeginMessage(); m_pSerializer->Init( _variant_t((IUnknown*)m_pConnector->InputStream)); // 构造信封 m_pSerializer->StartEnvelope(L"SOAP-ENV", L"", L"UTF-8"); m_pSerializer->StartBody(""); // 写方法名 m_pSerializer->StartElement( (_bstr_t)strMethod, (_bstr_t)m_strNameSpace, "NONE", ""); // 循环写参数 for (auto it = params.begin(); it != params.end(); ++it) { m_pSerializer->StartElement( (_bstr_t)it->first, (_bstr_t)m_strNameSpace, "NONE", ""); m_pSerializer->WriteString((_bstr_t)it->second); m_pSerializer->EndElement(); } // 闭合 m_pSerializer->EndElement(); m_pSerializer->EndBody(); m_pSerializer->EndEnvelope(); // 发送 HRESULT hr = m_pConnector->EndMessage(); if (FAILED(hr)) return _T(""); // 读取响应 m_pReader->Load( _variant_t((IUnknown*)m_pConnector->OutputStream), _T("")); return CString((const char*)m_pReader->RpcResult->text); } catch (_com_error e) { return (CString)(char*)e.Description(); } }改造后的调用方式变成一行:
std::map<CString, CString> params; params[_T("cmpcode")] = _T("0166"); params[_T("phone")] = _T("13258310354"); params[_T("content")] = _T("你好"); CString result = client.Invoke(_T("send"), params);参数顺序由std::map的键排序决定,如果服务端对参数顺序有要求,得换成std::vector<std::pair<CString, CString>>保持插入顺序。
5.3 超时控制与线程安全注意事项
封装成类之后,超时设置放在Init里统一配:
m_pConnector->Property["Timeout"] = "5000"; // 5 秒超时线程安全方面,COM 对象默认是 apartment 模型,跨线程调用需要CoInitializeEx指定COINIT_MULTITHREADED,或者每个线程单独创建客户端实例。我一般建议每个工作线程持有自己的CSoapClient实例,不要共享,省得处理封送问题。
5.4 响应解析的扩展:处理复杂返回结构
RpcResult->text只能取到简单文本返回值。如果服务端返回的是结构体或数组,需要遍历子节点:
// 假设返回结构里有 code 和 message 两个字段 IXMLDOMNodePtr pNode = m_pReader->RpcResult->childNodes->item[0]; CString strCode = (char*)pNode->selectSingleNode("code")->text; CString strMessage = (char*)pNode->selectSingleNode("message")->text;selectSingleNode支持 XPath 表达式,能比较方便地提取嵌套字段。注意节点名要和服务端 WSDL 里定义的一致,大小写敏感。
从那以后我每次封装 SOAP 客户端,都强制把超时、编码、SOAPAction 这三项做成可配置参数,不再写死在代码里。因为对接不同服务端时,这三项几乎必然要改,写死一次就得多编译一次,效率太低。希望帮到你。
本文还有配套的精品资源,点击获取