☰
VC++老系统手写SOAP报文调用WebService短信接口全流程
2026/10/9 12:16:59 网站建设 项目流程

简介:一份面向VC++开发者的技术参考资料,演示如何通过COM接口和SOAP协议调用远程Web Service。示例以短信发送服务为典型场景,完整覆盖了核心调用流程:创建HttpConnector30连接器、配置EndPointURL指向WSDL地址、通过Connect建立会话、设置SoapAction指定操作方法,再利用SoapSerializer30构建SOAP消息,将cmpcode、phone、content等参数依次写入消息体,并通过StartEnvelope、StartBody、StartElement等方法组织XML结构,最终调用EndMessage发送请求并读取响应结果。文档中对每个关键步骤均配有对应代码片段和简要说明,使读者能够理解SOAP消息的构造原理和COM组件的协作方式。对于需要在C++工程中集成Web服务的开发者,这份资料能有效缩短对SOAP协议和COM组件调用的摸索时间,提供可直接借鉴的排错思路。资源包以单个doc文档形式提供,压缩后大小仅28KB,内容凝练。目前已有179人学习/下载,适合具备一定VC++基础、希望快速掌握Web Service调用方法的开发者参考。

1. 一个完整的 SOAP 调用闭环:从 EndPointURL 到 RpcResult

很多 MFC 老系统跑了好多年,忽然要接一个短信 WebService。新平台用 C# 调这种接口很轻松,但老工程里没有代理类生成工具,只能在 VC++ 里手动拼 SOAP 包。这份资源正好给出一个完整的闭环:用一个函数、三个 COM 组件,把连接、拼报文、发送、解析返回值全部串起来。它解决的是「老代码不敢大动,又必须接远程 WebService」的真实场景。适合两类人:维护老系统、需要把 C/S 端接短信网关的开发者,以及想从 XML 报文层面理解 SOAP 调用的新手。下文按组件分工、调用步骤、踩坑点逐层拆开讲。

2. 为什么是 SOAP Toolkit 3.0:三个 COM 对象的分工与选型

2.1 VC6 时代没有 Add Service Reference,手写 SOAP 是主流

现在 VS 里右键添加服务引用,代理类自动生成,方法调用看起来和本地函数一样。但老工程不是这个玩法。VC6 和早期 VS2005 的 MFC 项目里,可选的方案基本是三条路。

第一条是用 WSDL 生成代理类。ATL Server 时代有过这类工具链,但生成代码体积大、模板复杂,WSDL 一更新就要重新生成,而且生成出来的代码在发布环境下经常编译不过。调试时你面对的是别人替你生成的类,一旦出问题很难定位是生成器的问题还是自己参数填错。维护老系统的人普遍不敢在这上面赌。

第二条是手工构造 XML 字符串,然后用 XMLHTTP 或 WinINet 发 POST 请求。这条路能走,但你需要自己管 HTTP 头、Content-Type、SOAPAction、命名空间前缀,还要自己处理响应 XML 的 DOM 解析。短信接口这种简单场景可以这么干,但只要涉及多方法、复杂类型,手写字符串的日子就非常难熬。

第三条就是这份资源采用的路线:用微软 SOAP Toolkit 3.0 提供的 COM 组件。它把 HTTP 传输、SOAP XML 序列化、响应解析拆成三个对象,但 XML 报文仍然在代码里原样可控。这段选择在我看来是当时最稳的方案:不用生成器,不碰原始 HTTP 细节,报文结构又透明,出问题可以很快定位到具体元素。

提示:SOAP Toolkit 3.0 是较早期方案,新版 Windows 上要注意组件注册情况,但很多存量工业软件里它仍然能正常跑。

2.2 三件套的职责边界:HttpConnector30 管传输、SoapSerializer30 管编包、SoapReader30 管解包

这份资源里最核心的三个 COM 对象是HttpConnector30、SoapSerializer30、SoapReader30。它们不是随手拼出来的组合,而是按照 SOAP 消息的完整生命周期分配的。

HttpConnector30是 HTTP 传输实现。它负责和远程 WebService 建立 TCP 连接,管理 HTTP 请求头,把消息体写到输出流,再读取服务端返回的响应流。资源里设置EndPointURL和SoapAction两个属性,再调Connect(),都是在和这个对象打交道。Connect()并不发送业务消息,它只是把 HTTP 连接准备好,真正发消息是后面EndMessage()触发的事。

SoapSerializer30负责把内存中的方法调用翻译成 SOAP XML 字节流。它不直接接触网络,而是绑定到连接器的InputStream上。资源里的Init(_variant_t((IUnknown*)SoapConnector->InputStream))就是把这个序列化器接到 HTTP 传输的写入端。之后StartEnvelope()、StartBody()、StartElement()、WriteString()、EndElement()这一串调用,实际是在内存里构建一棵 XML 树,最终写入流的是完整 SOAP 报文。

SoapReader30则做反向工作。Load()接收连接器的OutputStream,解析服务端返回的 XML,然后RpcResult->text取出方法结果的文本值。注意RpcResult是解析出来的结果节点,不是整个响应文档,这个区别很重要,很多人卡在「返回值拿不到」上,其实是拿错了节点。

三个对象按顺序工作:连接器开流,序列化器写请求,EndMessage 发送,读取器解析响应。资源里的代码是标准流水线,理解了这个链条,后面改造就顺了。

2.3 动手前先读 WSDL:targetNamespace、方法名、参数顺序都是坑源

拿到一个 WebService 地址,第一步不是写代码,而是打开?wsdl把文档读明白。资源里的代码写得比较紧凑,但里面有一个反复出现的字符串http://服务IP:端口/cif/services/SMS,它同时出现在 EndPointURL、命名空间和 StartElement 参数里,这就是没把 WSDL 里的概念拆开。

WSDL 文件里有几个东西必须分清楚。EndPointURL是服务实际接收请求的物理地址,通常是一个 URL;targetNamespace是这套接口的业务命名空间,用来标识方法归属;soapAction是服务端用来路由请求的标识;方法参数顺序则由 message/part 或 schema 的 sequence 定义。

一份典型 WSDL 片段长这样,注意看 targetNamespace 和访问地址的区别:

<wsdl:definitions targetNamespace="http://server:port/cif/services/SMS"> <wsdl:message name="sendRequest"> <wsdl:part name="cmpcode" type="xsd:string"/> <wsdl:part name="phone" type="xsd:string"/> <wsdl:part name="content" type="xsd:string"/> <wsdl:part name="receivedate" type="xsd:string"/> </wsdl:message> <wsdl:operation name="send"> <soap:operation soapAction="http://server:port/cif/services/SMS/send"/> </wsdl:operation> </wsdl:definitions>

很多人把 EndPointURL 直接抄进命名空间参数,服务端严格匹配时就翻车。我一般会在拿到 WSDL 后,把targetNamespace、方法名send、四个参数顺序抄到代码注释里,作为写序列化代码的唯一依据。这不是官方教程口吻,而是实际排障时的保命习惯:你的 StartElement 拼错一个命名空间,HTTP 层照样返回 200,但业务层就是失败,而且响应里还看不见明确错误码。

3. 把 BeginSoap 拆成调用流程:连接、编包、发送、拆包四段式

3.1 完整函数代码:保留原思路、修正字符集与占位符

资源里给出的是一个完整的BeginSoap函数。我把它按结构重排了,替换真实占位内容,补上关键注释,字符集处理上做了兼容处理,方便在不同版本的 VC 工程里直接抄:

// 功能:向短信WebService发送一条SOAP请求,返回服务端响应文本 // UserName / Password:有的网关要求放在HTTP Basic认证头里, // 也有的要求作为SOAP消息体参数,具体看WSDL定义 CString CWebservice_vcDlg::BeginSoap( CString UserName, CString Password, CString WebUrl) { HRESULT hr = S_OK; ISoapConnectorPtr SoapConnector; // HTTP传输连接器 ISoapSerializerPtr Serializer; // SOAP消息序列化器 ISoapReaderPtr Reader; // 响应读取器 // 命名空间:这里必须从WSDL的targetNamespace复制, // 不要直接填带?wsdl的访问地址(具体坑见第4章) char szNameSpace[] = "http://服务IP:端口/cif/services/SMS/"; try { // 1. 创建连接器实例,指定WebService终结点 SoapConnector.CreateInstance(__uuidof(HttpConnector30)); SoapConnector->Property["EndPointURL"] = _bstr_t("http://服务IP:端口/cif/services/SMS?wsdl"); // 2. 建立HTTP连接,失败时hr非0 hr = SoapConnector->Connect(); if (FAILED(hr)) return _T("Connect failed"); // 3. 指定SOAPAction,通常是"命名空间 + 方法名" SoapConnector->Property["SoapAction"] = _bstr_t(szNameSpace) + _bstr_t("send"); // 4. 开始写消息体 SoapConnector->BeginMessage(); // 5. 序列化器绑定到连接器的输入流 Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer->Init(_variant_t((IUnknown*)SoapConnector->InputStream)); // 6. 构造SOAP XML树:Envelope -> Body -> send -> 参数 Serializer->StartEnvelope("SOAP-ENV", "", "UTF-8"); Serializer->StartBody(""); Serializer->StartElement("send", szNameSpace, "", ""); { Serializer->StartElement("cmpcode", szNameSpace, "NONE", ""); Serializer->WriteString("你的企业代码"); Serializer->EndElement(); Serializer->StartElement("phone", szNameSpace, "NONE", ""); Serializer->WriteString("13800138000"); Serializer->EndElement(); Serializer->StartElement("content", szNameSpace, "NONE", ""); Serializer->WriteString("你好,这是一条测试短信"); Serializer->EndElement(); Serializer->StartElement("receivedate", szNameSpace, "NONE", ""); Serializer->WriteString(""); Serializer->EndElement(); } Serializer->EndElement(); // 结束 send Serializer->EndBody(); Serializer->EndEnvelope(); // 7. 真正把消息发送给服务端 hr = SoapConnector->EndMessage(); if (FAILED(hr)) return _T("EndMessage failed"); // 8. 创建读取器,解析响应流 Reader.CreateInstance(__uuidof(SoapReader30)); Reader->Load(_variant_t((IUnknown*)SoapConnector->OutputStream), _T("")); // 9. 取结果节点文本 CString strResp; if (Reader->RpcResult != NULL && Reader->RpcResult->text != NULL) strResp = CString(Reader->RpcResult->text); return strResp; } catch (_com_error e) { return (CString)(char*)e.Description(); } }

逐段说逻辑。前 5 步属于「准备阶段」:创建组件、连接、指定动作、开启消息流、绑定序列化器。Connect()只保证 HTTP 通道通,不代表服务端认可你的业务请求。第 6 步是核心编包过程,StartEnvelope指定 SOAP 信封和编码格式,StartBody开启消息体,随后send是服务端方法名,四个参数按 WSDL 里定义的顺序逐个序列化。第 7 步EndMessage()是真正发消息的时机,这里hr失败通常意味着 HTTP 层有问题。第 8、9 步解析响应,注意RpcResult->text只在业务正常返回时有值,如果服务端返回 SOAP Fault,这里拿到的是空,需要额外处理。

参数说明里有两个容易被忽略的点。第一个是Property["SoapAction"],很多服务端对soapAction字符串做精确匹配,szNameSpace末尾的斜杠必须和 WSDL 里定义一致,多一个少一个都可能路由失败。第二个是StartElement的第三个参数,这里传"NONE"在老版本组件里代表不生成前缀,新工程里用空串也可以,作用不大,但传错了会有运行时异常。

3.2 参数序列与命名空间:cmpcode、phone、content、receivedate 的顺序不能改

SOAP 调用里最常见的错误不是值填错,而是参数顺序错位。很多服务端框架严格按 WSDL 里 sequence 定义的下标反序列化,你把 phone 写在 content 前面,服务端可能把手机号当短信内容解析,表面看请求发出去了,实际业务全错。

这份资源的四个参数含义如下表:

元素名含义注意事项
cmpcode企业接入代码服务端分配,固定字符串,错一位就失败
phone接收短信的手机号确认服务端要不要国家码前缀
content短信内容编码格式要和 Namespace 声明一致
receivedate预约发送时间传空串表示立即发送

命名空间的继承规则也要清楚。这里send元素声明了命名空间,它内部的cmpcode、phone如果沿用同一命名空间,可以直接继承;但资源里每个子元素都重复写了一遍命名空间,这样做的好处是报文独立、不依赖父元素声明,坏处是代码啰嗦,而且命名空间写错一个字母就全部失效。实践中我一般只在根元素声明命名空间,子元素靠继承,既能缩小报文体积,也减少了出错点。不过前提是你对服务端的 namespace 处理有信心,遇到严格要求前缀的网关,还得回到逐元素声明。

参数值的类型也要匹配。WriteString是通用写法,如果某个参数声明为xsd:int,服务端反序列化时可能对字符串宽容,但严格模式下要调用WriteInt之类的方法。遇到这类参数,先看 WSDL 的 type 定义再决定用哪个序列化方法。

3.3 响应解析:RpcResult->text 拿到的到底是什么

很多照着这份资源抄的人会在最后一步卡住:Connect()成功、EndMessage()成功、Load()也不报错,但RpcResult->text就是空的,或者拿到一串看不懂的 XML 片段。

RpcResult是 SOAP Toolkit 从响应 XML 里提取出来的第一个子结果节点,text则是该节点下的文本内容。对于短信网关这类方法,通常服务端返回的是一个布尔值、一个编号或一段状态描述。如果服务端返回的结构是嵌套 XML,text拿到的只是最底层文本,节点之外的内容会被丢弃。

资源里的代码用Reader->RpcResult->text直接返回,没有处理 Fault 的情况。当服务端抛出异常,响应里包含<soap:Fault>节点时,RpcResult很可能为空,函数返回空字符串,调用方就会把「服务端报错」误判成「返回空结果」。我在实际项目里会做一层兜底:RpcResult为空时,尝试从Reader->GetFaultString()取错误描述,再决定返回什么内容。这个小改动能省掉大量排查时间。

另外注意Load()的第二个参数传了空字符串,这是处理命名空间匹配的过滤条件,一般传空即可。但如果你发现RpcResult始终取不到值,可以尝试传服务端响应的 targetNamespace 来缩小匹配范围。

4. 避坑:命名空间、字符集与 COM 生命周期的四个现场

这一章把实际接短信网关时最容易翻车的四个问题逐一拆开,每条都按「现象 → 原因 → 解决」的顺序写,可以直接对着排查。

4.1 命名空间填了带 ?wsdl 的地址,服务端返回空响应

现象:Connect()成功,EndMessage()成功,抓包看报文也已经发出去,服务端返回的却是空字符串或者固定错误码,换各种参数都一样。

原因:资源代码里StartElement("send", "http://服务IP:80/cif/services/SMS?wsdl", "", "")这种写法把带?wsdl的访问地址当成了命名空间。WSDL 里的targetNamespace通常是http://服务IP:端口/cif/services/SMS,没有?wsdl后缀,也没有?SMS这种查询串。服务端按targetNamespace匹配方法,命名空间对不上,方法就找不到,返回的 Fault 信息还被你没解析就扔掉了。

解决:先把 WSDL 拉下来,找到<wsdl:definitions targetNamespace="...">,把那个值原样复制到StartElement里。注意对比末尾有没有斜杠,命名空间对斜杠敏感。修改后重新抓包,请求的命名空间声明应该和 WSDL 一致。从那以后我遇到这种问题,都先打开报文看命名空间声明,而不是猜参数。

4.2 Unicode 工程下把 CString 转 char*,中文全部丢光

现象:在 VS2005 以上版本打开带这份代码的工程,编译报警甚至报错,强行跑起来后中文短信内容变成问号,或者响应字符串乱码。

原因:VC6 时代的CString在 ANSI 工程里本质是char*,但 VS2005 以后默认字符集是 Unicode,CString内部是宽字符。资源里的CString((const char*)Reader->RpcResult->text)在 Unicode 工程下会把 BSTR 窄化,字符集转换没做好,中文自然全丢。发送端也一样,WriteString期望的是 BSTR,你传入窄字符串,编码就错了。

解决:发短信前统一用CW2A把CString转成 UTF-8 或 GBK 字节流(取决于服务端解析方式),响应回来用CString(Reader->RpcResult->text)让编译器走 BSTR 重载。如果工程老代码太多改不动,至少把BeginSoap内部单独处理字符集,不要在函数边界混用窄宽字符。实际排查时可以先发一条纯英文短信验证通道,再测中文,这样能区分是编码问题还是通道问题。

4.3 忘了 CoInitialize / AfxOleInit,CreateInstance 直接报 0x800401F0

现象:在某个线程里调用BeginSoap,第一行SoapConnector.CreateInstance(__uuidof(HttpConnector30))就抛异常,hr 返回 0x800401F0,代码还没跑到 Connect 就退了。

原因:0x800401F0 是CO_E_NOTINITIALIZED,意思是当前线程的 COM 库没有初始化。SOAP Toolkit 的组件是 COM 对象,依赖当前线程调用过CoInitialize或CoInitializeEx。对话框程序在OnInitDialog里不一定会自动初始化 COM,控制台程序更是默认什么都没有。

解决:在程序启动处调用AfxOleInit()(MFC 工程),或者在使用前手动CoInitialize(NULL),用完CoUninitialize()。注意 COM 初始化是按线程的,如果你把BeginSoap放到工作线程里跑,那个线程也要单独初始化。我之前遇到过一个诡异现象:主界面调用好好的,切到后台线程就报 0x800401F0,原因就是工作线程没初始化 COM。

4.4 函数里多个 return " ",失败原因变成黑匣子

现象:调用BeginSoap返回空字符串,你根本不知道是连接失败、EndMessage 失败、还是服务端正常返回了空结果。每换一套网关参数,都得重新抓包或加日志定位。

原因:资源里的代码所有失败路径都统一返回" ",把错误信息全吞掉了。_com_error的Description()只在异常分支里返回,但Connect失败和EndMessage失败这两处 hr 检查直接返回了空串,调用方看到的结果和业务端「短信发送失败」没有任何区别。

解决:把失败路径改成拼错信息后返回,比如_T("Connect failed, hr=0x%08X"),或者增加一个输出参数接收详细错误。响应解析部分也要区分「RpcResult 正常空值」和「解析异常」。我习惯在BeginSoap里加一个日志打印,把 hr、返回文本、异常描述全部写进调试输出,这样对接服务商时可以直接把日志发过去,省去来回抓包的时间。

5. 把短信通道改造成通用网关:参数表与验证技巧

5.1 参数表驱动:循环构造 SOAP 元素,避免每个方法都重写一遍

原资源的函数把cmpcode、phone、content、receivedate四个参数写死在代码里。实际工程里,你可能要接短信方法、余额查询方法、状态报告查询方法,每个方法的参数列表都不一样。把编包部分改成参数表驱动,代码复用性会好很多:

typedef struct _TagSoapParam { const char* name; // 参数元素名 const char* value; // 参数值(已转成UTF-8) } TagSoapParam; // 通用方法:按参数数组构造SOAP消息体 HRESULT BuildSoapBody(ISoapSerializerPtr& Serializer, const char* szMethod, const char* szNamespace, TagSoapParam* params, int nCount) { Serializer->StartElement(szMethod, szNamespace, "", ""); for (int i = 0; i < nCount; i++) { Serializer->StartElement( params[i].name, szNamespace, "NONE", ""); Serializer->WriteString(params[i].value); Serializer->EndElement(); } Serializer->EndElement(); // 结束方法元素 return S_OK; }

调用处只需要维护一个参数数组,发短信传四个参数,查余额传一个参数。这个方法数组本身也从外部配置读入,网关字段变更时只改数据不改代码。注意参数值统一在进入这个函数前转好编码,不要在 WriteString 里做窄宽转换,否则每个参数都可能出问题。

5.2 先用测试方法打通链路,再验证业务参数

拿到一份新的 WebService,不要一上来就跑真正的send方法。先看 WSDL 里有没有test、getVersion、ping这类无副作用的探测方法,用它们把链路走通。这样你验证的是「HTTP 通不通、SOAP 解析对不对、命名空间对不对」这些基础项,而不是被业务参数同时干扰。

具体套路是:先用getVersion或ping这类返回固定文本的方法,确认RpcResult->text能正常取到值;再换成本文第一个短信参数,确认cmpcode是否正确;最后再上真实手机号发一条短信。每加一个变量只验证一件事,出问题好定位。

如果服务端没有探测方法,也有个土办法:用错误的cmpcode发一次请求,服务端通常会返回鉴权失败的具体错误文本,这个文本本身就是链路正常的证据。重点在于观察返回内容,而不是只看调用是否成功。

另外,调试时把SoapConnector->OutputStream的原始响应流打印出来,比看RpcResult->text直观得多。响应流里能看到整个 SOAP 响应结构,包括 Fault 的具体原因。

这个资源我已经反复用过多次。最初照着抄时,命名空间和字符集问题各踩过一次,后来养成了习惯:拿到新网关先抄 WSDL 的 targetNamespace,再调探测方法,最后才动业务参数。无论是维护老系统还是接新通道,这套流程都能帮你快速落地。希望帮到你。

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

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

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

立即咨询