简介:面向需要在VC++中集成远程接口的开发者,这份文档围绕通过COM组件与SOAP协议调用Web Service展开,重点解决本地桌面程序与服务器端短信发送类服务之间的通信问题,适合具备一定C++与COM基础、希望补齐Web Service调用链路的初中级程序员参考。包内共1个doc文档,约28KB,以代码与要点说明为主,篇幅紧凑但信息集中。现有180人学习下载,说明该主题在接口联调场景中仍有稳定需求。内容给出BeginSoap封装函数及关键调用片段,涵盖创建HttpConnector30连接器、设置EndPointURL与SoapAction、连接服务、初始化SoapSerializer30、按Envelope/Body/Element层级写入cmpcode、phone、content等参数、EndMessage发送以及通过SoapReader30读取RpcResult响应的完整流程,并保留_com_error异常描述返回。文档以实际可运行的函数为线索,将连接、序列化、发送、响应读取串成闭环,便于对照排查连接失败、命名空间设置或参数格式等问题。也可作为模板嵌入已有MFC工程,替换服务地址、命名空间与参数后适配同类接口,并借助异常信息定位连通性、SOAPAction不匹配或XML节点拼装等问题。
1. VC 调用 WebService 的真实场景:老 MFC 程序怎么跟远端接口打交道
前阵子帮一个现场排查问题:工控机上的 VC++ 程序要调远端短信接口,服务端只丢过来一个 WSDL 地址和一句“方法名 send”,机器上装不了 .NET,也编译不了第三方源码。这就是 VC 调用 WebService 最典型的处境——调用方是十几年前的老 MFC 工程,被调用方是标准 SOAP 服务,中间没有现成的 SDK 可以对接。这类活儿拆开看只有四段:建立连接、组装报文、发送、解析返回,但每一段都藏着一两个让程序跑不通的细节。这套方案适合还在维护 VC6/VS2005 工程、需要对接第三方 SOAP 接口却拿不到 C++ 头文件的开发者,也适合课程设计要演示“本地程序调用远程服务”的同学,把地址和字段换掉就能直接用。
2. 技术选型:四条路走下来,为什么落到 SOAP Toolkit 的 COM 组件
2.1 四种常见做法的代价对比
VC++ 调远端 SOAP 接口,市面上能走的路其实只有四条,选哪条取决于工程年代、接口数量和现场部署权限。先把账算清楚,再谈实现。
| 做法 | 依赖 | 改动量 | 适用场景 |
|---|---|---|---|
| SOAP Toolkit 3.0 的 COM 组件 | mssoap30.dll | 小,纯 C++,不引入第三方源码 | 老 MFC 工程,接口方法少(1~5 个) |
| 手工拼 XML + MSXML 发 HTTP POST | MSXML 组件 | 中,报文完全自己控制 | 服务端报文格式特殊,或需要精细控制头部 |
| 用工具生成 C++ 存根代码 | 生成的 .c/.h 文件 | 大,要把第三方源码编进工程 | 接口字段多、方法多,一次对接长期使用 |
| 单独做一层 .NET 中间进程 | .NET 运行时 | 现场要额外部署和守护一个进程 | 现场允许装运行时,且团队有人会维护 |
第一种是原地解决:工程里#import一下类型库,代码里直接CreateInstance,不动编译选项、不加源码目录,对祖传工程最友好。第二种可控性最好,但你得自己处理 XML 转义、UTF-8 编码、命名空间前缀和 Content-Length,一个&没转义服务端就报 400,血泪经验是这类问题排查起来比写代码本身还费时间。第三种在接口成规模时最划算,问题在于很多生成器输出的 C++ 代码按新标准写,往 VC6 工程里一放就是几百个编译错误,赶工期的时候不建议碰。第四种本质上是把问题挪走,不算解决。
我这里选第一种,理由很实际:接口只有一个send方法、四个参数,现场机器上已经装了 Office 类组件环境,mssoap30.dll本来就存在,不用额外申请安装权限。
2.2 三个 COM 对象各管什么:连接器、序列化器、读取器
SOAP Toolkit 把一次调用拆成三个对象,理解它们的职责边界,后面写代码就不会乱。
HttpConnector30实现的是ISoapConnector,负责 HTTP 传输层:它知道往哪个地址发、用哪个 SoapAction、超时多少、要不要认证。它还暴露了两个流对象——InputStream是“我要写进去的报文”,OutputStream是“服务端返回来的报文”。连接器本身不懂 XML,它只是个搬运工。
SoapSerializer30实现ISoapSerializer,负责往InputStream里写 XML。它提供StartEnvelope、StartBody、StartElement、WriteString、EndElement这一组方法,本质是一个带状态的 XML 写入器,好处是标签闭合、编码声明、命名空间属性这些容易写错的东西它替你处理了。
SoapReader30实现ISoapReader,负责读OutputStream。Load之后可以从RpcResult拿到返回节点,从Fault拿到错误节点。这里有个关键认知:只有 RPC 风格的结果才会填进RpcResult,如果服务端返回的是 document 风格或者干脆是异常,RpcResult会是空指针,直接取.text就是访问越界。
2.3 环境准备:#import、CoInitialize 与 dll 部署
三件事在写第一行业务代码之前必须做完,缺一件后面全是玄学崩溃。
// stdafx.h 或 soap 封装类的头文件里 // 导入 SOAP Toolkit 的类型库,生成智能指针包装 #import "C:\Program Files\Common Files\MSSoap\Binaries\mssoap30.dll" \ no_namespace \ exclude("IStream", "IErrorInfo", "ISequentialStream")no_namespace是让生成的类型不加命名空间前缀,exclude里的三项必须排掉:IStream和ISequentialStream在 Windows 平台头文件里已有定义,不排除会和<objidl.h>撞车,报一堆“重复定义”;IErrorInfo同理。这三项不加,工程基本编不过。
第二件事是 COM 初始化。MFC 对话框程序在InitInstance里调一次AfxOleInit()即可;但只要你把调用放到工作线程里,就必须在线程入口自己初始化:
// 工作线程入口,每个线程都要走一遍 HRESULT hrCo = ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); if (FAILED(hrCo)) { // RPC_E_CHANGED_MODE 说明该线程已经初始化过,可以继续用 if (hrCo != RPC_E_CHANGED_MODE) return; } // ... 在这里调用 SOAP 封装 ::CoUninitialize();参数用COINIT_APARTMENTTHREADED,SOAP Toolkit 的组件是套间线程模型,用多线程套间容易出奇怪问题。CoUninitialize必须和CoInitializeEx成对出现,否则线程退出时组件不释放,跑一晚上内存就上去了。
第三件事是部署。目标机器上要有mssoap30.dll(通常在Common Files\MSSoap\Binaries下)和 MSXML,缺任何一个,CreateInstance直接返回0x80040154(类未注册)。我的做法是在程序启动时做一次自检,注册表里查不到 CLSID 就弹一个明确的提示,而不是让用户面对一个“发送失败”。
3. 通信层总体设计:把 BeginSoap 拆成可复用的四个阶段
3.1 分层结构与模块划分
原始代码把所有逻辑塞在一个BeginSoap函数里,短是短,但换个接口就要复制一遍,参数一变就得改函数签名。我的处理是拆成三层,职责单一。
最上面是界面层:对话框只负责收集三个输入框的值(商户编码、手机号、短信内容),拿到结果字符串后决定弹提示还是写日志,不碰任何 COM 指针。
中间是业务封装层CSmsSoapClient:持有地址、命名空间、方法名三个配置项,对外只暴露SendSms一个方法,内部完成组包和解析,并把各种失败翻译成可读的错误串。
最下面是传输层:三个 COM 智能指针对象,生命周期完全被封装层管住,出了这个类就没有人知道 SOAP 的存在。这样以后要换成 HTTP POST 直发,只改封装层的实现,界面层一行不动。
配置项集中成常量,别散落在函数里:
// 服务地址、命名空间、方法名集中管理,换环境只改这三行 static const TCHAR* SMS_ENDPOINT = _T("http://10.10.20.30:8080/cif/services/SMS"); static const TCHAR* SMS_NAMESPACE = _T("http://10.10.20.30:8080/cif/services/SMS"); static const TCHAR* SMS_METHOD = _T("send");注意这里SMS_ENDPOINT不带?wsdl。WSDL 是服务描述文档的地址,真正的调用端点是不带查询串的那个 URL,这两者混用是新手最容易栽的一跤,后面避坑那章会细说。
3.2 接口定义:入参与返回值的约定
封装类的头文件大致长这样:
class CSmsSoapClient { public: CSmsSoapClient(); virtual ~CSmsSoapClient(); // 单条短信发送:商户编码、手机号、内容 // 成功返回服务端原始结果串,失败返回空串,原因从 GetLastError 取 CString SendSms(const CString& strCmpCode, const CString& strPhone, const CString& strContent); CString GetLastError() const { return m_strLastError; } private: BOOL PrepareConnector(ISoapConnectorPtr& spConn); void AddParam(ISoapSerializerPtr& spSer, LPCTSTR lpszName, LPCTSTR lpszValue); CString m_strLastError; };返回值设计成“空串代表失败、错误原因单独取”,而不是把错误串当返回值混在业务结果里。原因很现实:服务端返回的成功标识本身可能就是一个数字串,和错误串没法区分,调用方判断起来很容易写错。原来的BeginSoap里连接失败return " "、异常时return e.Description(),调用方根本分不清这个字符串是报文还是错误。
顺带说一句,原函数签名里的UserName、Password在这个短信服务里其实没被用到——服务是靠报文里的cmpcode识别商户的。真要加 HTTP 基本认证,连接器有UserName、Password两个属性可以设,但在报文层传账号是另一回事,别混。
3.3 一次调用的完整时序
一次成功的调用固定走八步,顺序错了就发不出去,我把它们列清楚,写代码时对着走:
CreateInstance(__uuidof(HttpConnector30))创建连接器;- 设
EndPointURL、SoapAction、Timeout三个属性; Connect()建立 HTTP 连接;BeginMessage()告诉连接器“我要开始写了”;- 创建序列化器,
Init挂到InputStream上,写 Envelope、Body、参数元素、闭合; EndMessage()真正把报文发出去;- 创建读取器,
Load挂到OutputStream上; - 从
RpcResult取结果文本。
其中第 3 步和第 6 步都可能失败,但失败含义完全不同:Connect失败通常是地址写错、端口不通、服务没起;EndMessage失败通常是报文本身有问题被服务端拒了。把这两处的错误码分开记录,排查时能省一半时间。
4. 关键实现:SOAP 消息体怎么拼才不会被服务端拒绝
4.1 连接器的三个属性:地址、动作、超时
先把连接这一步写扎实。连接器创建后不是立刻能用的,三个属性设完才算就绪。
BOOL CSmsSoapClient::PrepareConnector(ISoapConnectorPtr& spConn) { HRESULT hr = spConn.CreateInstance(__uuidof(HttpConnector30)); if (FAILED(hr)) { m_strLastError.Format(_T("创建连接器失败 0x%08X"), (unsigned)hr); return FALSE; // 多半是 mssoap30.dll 未注册 } // 真正的调用端点,不能带 ?wsdl spConn->Property["EndPointURL"] = _variant_t(SMS_ENDPOINT); // SoapAction = 命名空间 + "/" + 方法名,斜杠不能少 CString strAction; strAction.Format(_T("%s/%s"), SMS_NAMESPACE, SMS_METHOD); spConn->Property["SoapAction"] = _variant_t(strAction); // HTTP 超时,单位毫秒,不设的话网络不通时会卡很久 spConn->Property["Timeout"] = _variant_t(15000L); hr = spConn->Connect(); if (FAILED(hr)) { m_strLastError.Format(_T("连接服务失败 0x%08X"), (unsigned)hr); return FALSE; // 地址错、端口不通、服务未启动 } return TRUE; }Property是一个以属性名为下标的读写接口,#import生成的包装把它展开成属性访问,所以可以直接用方括号赋值。EndPointURL决定 TCP 连到哪;SoapAction会被写进 HTTP 头,服务端靠它路由到具体方法,值必须严格等于 WSDL 里soapAction属性的内容,通常是“命名空间 + 斜杠 + 方法名”;Timeout建议显式设,默认值在部分版本上偏长,网络一断客户端就假死。
4.2 序列化器:Envelope 与 Body 的两层结构
连接建立后开始写报文。SOAP 报文的外壳是固定的两层:Envelope 包住 Body,Body 里放方法元素。
spConn->BeginMessage(); // 进入可写状态 ISoapSerializerPtr spSer; spSer.CreateInstance(__uuidof(SoapSerializer30)); // 把序列化器的输出接到连接器的输入流上 spSer->Init(_variant_t((IUnknown*)spConn->InputStream)); spSer->StartEnvelope(_T(""), _T(""), _T("UTF-8")); // 声明编码,不额外加头 spSer->StartBody(_T("")); // Body 不额外声明命名空间 // 方法元素:名字 + 命名空间,前缀留空由序列化器自行处理 spSer->StartElement(_bstr_t(SMS_METHOD), _bstr_t(SMS_NAMESPACE), _T(""), _T("")); // --- 参数元素在这里插入,见 4.3 --- spSer->EndElement(); // 闭合方法元素 spSer->EndBody(); spSer->EndEnvelope();StartEnvelope三个参数依次是 Header 内容、命名空间、编码方式,前两个传空串表示不额外声明,编码固定写UTF-8,中文内容必须靠它,写成GBK或者不写,服务端收到的就是乱码。StartBody的参数同理,一般传空,让方法元素自己带命名空间。StartElement的第三个参数是 XML 前缀,传空串或者"NONE"都表示不加前缀,两种写法在 Toolkit 里效果一致。
方法元素的命名空间必须和 WSDL 里的targetNamespace一字不差,连端口号都要对上。这是整个流程里最容易出错的地方,因为写错了Connect依然会成功,只有EndMessage返回失败,错误信息还很含糊。
4.3 参数元素:命名空间、空值与转义
参数是方法元素的子节点,每个参数都要带上与方法元素相同的命名空间。用一个辅助函数收口,避免每加一个字段就复制三行:
void CSmsSoapClient::AddParam(ISoapSerializerPtr& spSer, LPCTSTR lpszName, LPCTSTR lpszValue) { // 第二个参数不能省:参数元素缺命名空间,多数服务端会直接判为无效请求 spSer->StartElement(_bstr_t(lpszName), _bstr_t(SMS_NAMESPACE), _T(""), _T("")); if (lpszValue != NULL && _tcslen(lpszValue) > 0) { spSer->WriteString(_bstr_t(lpszValue)); // 内容里的 & < > 由序列化器负责转义 } spSer->EndElement(); }调用处就很干净:
AddParam(spSer, _T("cmpcode"), _T("0166")); // 商户编码,服务端据此计费 AddParam(spSer, _T("phone"), _T("13800000000")); // 接收号码,纯数字不带分隔符 AddParam(spSer, _T("content"), _T("测试短信")); // 短信正文,注意长度限制 // receivedate 定时发送时间,不传表示立即发送,交给服务端取当前时间WriteString内部会处理& < >的转义,这一点比手工拼字符串强太多——手工拼的话,短信内容里只要出现一个&,服务端就报 XML 解析错误,而且错误信息里不会告诉你哪一行错了。
关于receivedate这类日期字段:如果接口要求传,格式必须按 WSDL 里定义的xsd:dateTime,通常是yyyy-MM-ddTHH:mm:ss;如果不要求,最稳妥的做法是整个元素不写,而不是写一个空元素。空元素在某些服务端实现里会被解析成空字符串再转日期,直接抛异常。原文里WriteString("")后再EndElement,就是典型的踩坑写法。
4.4 读取响应:RpcResult 与 BSTR 转换
报文发出去之后,先判断发送本身成不成功,再去解析返回。
HRESULT hr = spConn->EndMessage(); // 到这里报文才真正发出去 if (FAILED(hr)) { m_strLastError.Format(_T("发送失败 0x%08X"), (unsigned)hr); return CString(); // 多半是报文格式或 SoapAction 被拒 } ISoapReaderPtr spReader; spReader.CreateInstance(__uuidof(SoapReader30)); spReader->Load(_variant_t((IUnknown*)spConn->OutputStream), _T("")); if (spReader->RpcResult == NULL) { // 没有 RPC 结果,看看是不是服务端返回了 Fault m_strLastError = _T("服务端未返回结果"); return CString(); } _bstr_t bstrRet(spReader->RpcResult->text); return CString((LPCTSTR)bstrRet); // Unicode 工程用 LPCTSTR,ANSI 工程用 (LPCSTR)这里有三处必须注意。第一,RpcResult在取用之前一定要判空,服务端返回 Fault 或者返回结构不是 RPC 风格时它就是 NULL,直接->text是空指针解引用,程序当场崩。第二,RpcResult->text的类型是BSTR,它是带长度前缀的宽字符串,不能强转成char*,强转得到的是宽字符序列按字节读出来的乱码,看着像“每个字中间夹一个方框”。第三,Unicode 工程和 ANSI 工程的转换写法不一样,_bstr_t会自动按工程配置选窄宽,用(LPCTSTR)一把梭最省事。
4.5 异常兜底:把 COM 错误翻译成人话
整套流程外面必须包一层try/catch,捕获_com_error。COM 组件抛出的是结构化异常,不接住的话程序直接终止。
catch (_com_error& e) { // Error() 是 HRESULT,Description() 是 BSTR 描述 m_strLastError.Format(_T("COM 错误 0x%08X | %s"), (unsigned)e.Error(), (LPCTSTR)(_bstr_t)e.Description()); return CString(); }e.Error()给出 HRESULT,能直接查资料;e.Description()是组件自带的描述串,有时候比错误码有用得多。两个都记进日志,出问题时不用猜。这里我把错误串单独存进成员变量而不是当返回值,调用方先看返回值是否为空,再看GetLastError(),逻辑清晰,也不会把错误信息和业务数据混在一起。
上层调用就这么几行:
CSmsSoapClient client; CString strRet = client.SendSms(_T("0166"), _T("13800000000"), _T("测试短信")); if (strRet.IsEmpty()) { AfxMessageBox(_T("发送失败:") + client.GetLastError()); }5. 避坑与常见问题排查:五个最容易翻车的点
5.1 连接阶段的三个坑
现象:Connect返回成功,但EndMessage一直失败,HTTP 状态 404 或 500。原因:EndPointURL里带了?wsdl。?wsdl是取服务描述文档的地址,浏览器能打开它不代表它是调用端点,POST 到这个地址服务端会当成一次文档请求处理。 解决:把EndPointURL改成不带查询串的裸地址,?wsdl只留给你自己用浏览器看接口定义。
现象:服务端返回 Fault,提示不识别 SOAPAction。原因:SoapAction拼错。常见三种错法——命名空间末尾多一个空格(原文szNameSpace后面就带了两个空格)、命名空间和方法名之间漏了斜杠、URL 里少写或多写端口号。 解决:打开 WSDL 文件,搜soapAction,把引号里的字符串原封不动复制过来当常量,别自己拼。拼出来的代码看着优雅,出问题时对不上一丁点字符都不行。
现象:中文全变问号或者乱码。原因:Envelope 的编码声明写成了别的值,或者服务端按 GBK 解。 解决:StartEnvelope第三个参数固定写UTF-8,同时确认工程字符集。VC6 默认 ANSI,_bstr_t会把窄串按当前代码页转宽再转 UTF-8,中文系统下一般没问题;如果现场是英文系统,就得先自己转成 UTF-8 再WriteString。
5.2 报文与解析阶段的三个坑
现象:返回结果里每个字符后面跟着一个\0,或者直接乱码。原因:把BSTR当char*用了。Reader->RpcResult->text是宽字符串,而且长度信息在指针前面四个字节,强转成char*后CString按窄串构造,读到第一个\0就截断,或者把宽字符的低字节拼成一串垃圾。 解决:一律用_bstr_t中转,CString((LPCTSTR)_bstr_t(spReader->RpcResult->text)),窄宽两种工程都能编过。
现象:Load没报错,但RpcResult是空指针,程序崩在取.text那一行。原因:两种可能。一是 Body 里方法元素的命名空间写成了带?wsdl的地址,服务端按未知方法处理,返回的是 Fault;二是这个接口本身就是 document 风格,结果不在RpcResult里。 解决:取RpcResult之前先判空,为空时改从spReader->Fault取错误节点,把faultstring打出来。这一步加上,线上问题一半能自己定位。
现象:服务端报参数格式错误,但字段名和值看着都对。原因:空值参数写成了空元素,比如<receivedate></receivedate>。服务端拿到空串去转日期或转数字,直接抛异常。 解决:不需要传的字段整个元素都别写,而不是写空内容。辅助函数里判一下空串直接跳过WriteString,连StartElement都可以省掉。
5.3 运行环境与线程的坑
现象:主线程调用一切正常,放到工作线程里就返回0x800401F0或者直接崩。原因:0x800401F0是“COM 未初始化”。AfxOleInit()只在主线程生效,工作线程必须自己CoInitializeEx。 解决:线程入口统一走一遍初始化,退出前CoUninitialize;同时确认没有跨线程传递 COM 接口指针,每个线程各自创建自己的连接器和序列化器,用完即释放。
另外两个排查习惯值得养成:一是把最终生成的请求 XML 落盘一份,出问题时直接拿去和接口文档逐字段比对,比盯着代码猜快得多;二是服务地址、命名空间、方法名全部做成配置项,现场换环境不用重新编译,改配置文件重启即可。
6. 验收与答辩:怎么证明这套调用真的跑通了
6.1 三组对照测试,缺一组都不算完
第一组是基准组:先用支持 SOAP 的接口调试工具,按 WSDL 手填一遍参数发出去,确认服务端本身是通的,把成功的请求 XML 和响应 XML 存下来当基准样本。这一步能挡掉“代码有问题”和“服务端有问题”的扯皮。
第二组是单次调用组:用封装好的CSmsSoapClient发一条,把程序落盘的请求 XML 和基准样本逐字段对比。字段顺序、命名空间、编码声明三项对上了,基本就通了。我见过太多情况是代码逻辑没错,纯粹是命名空间多了个空格,对比一下两分钟解决。
第三组是异常组:故意把地址改成不存在的端口、把手机号改成空串、把内容填一百多个字、断网之后再发一次,确认四种情况下程序都不崩、都能给出可读的错误提示。这一组最能体现工程完成度,答辩时演示这个比演示成功发送有说服力得多。
6.2 日志与返回码对照表
程序里固定记三类日志:请求 XML、响应 XML、耗时。返回码按下面这张表归类,出错时按表查,不用每次从头翻代码。
| 返回情况 | 常见原因 | 排查方向 |
|---|---|---|
创建连接器失败0x80040154 | 组件未注册 | 检查 mssoap30.dll 是否存在并注册 |
Connect失败 | 地址错、端口不通、服务未启动 | 用浏览器访问端点地址确认可达 |
EndMessage失败 | 报文格式、SoapAction 不匹配 | 对比落盘的请求 XML 与基准样本 |
RpcResult为空 | 返回 Fault 或非 RPC 风格 | 读Fault节点取 faultstring |
| 返回内容乱码 | BSTR 强转、编码声明错 | 改_bstr_t中转,确认 UTF-8 声明 |
这张表建议直接贴在验收文档里,接手的人看一眼就知道从哪下手。
6.3 答辩现场最容易被问到的三个问题
第一个:“为什么不用现成的客户端生成工具?” 回答思路是接口只有一个方法、字段固定,引入第三方源码在 VC6 下编译成本高于收益,而且现场部署只多一个 dll,不影响安装包体积。
第二个:“网络断了程序会不会卡死?” 回答要落到Timeout属性上,说明设了 15 秒超时,并且Connect和EndMessage两处都做了失败分支,不会无限等待。
第三个:“服务端接口改了怎么办?” 回答要落到配置项上,地址、命名空间、方法名、参数字段名都在一处维护,改字段只动AddParam那几行,界面层零改动。
从那以后我每次对接这类老式 SOAP 接口,都会强制自己先跑一遍基准样本对比,再谈代码写得对不对——中间隔着的那一层 XML,看不见摸不着,只能靠对比。这套流程不复杂,难的是每个环节都别想当然。希望帮到你。
本文还有配套的精品资源,点击获取