ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC/MFC调用SOAP短信网关:COM组件与WSDL配置避坑

VC/MFC调用SOAP短信网关:COM组件与WSDL配置避坑

简介:面向使用VC++进行桌面或后端集成开发的程序员,这份文档围绕通过COM接口与SOAP协议调用Web Service展开,适合需要把本地程序接入远程服务、尤其是短信发送类接口的读者参考。压缩包仅含1个doc文件,大小约28KB,篇幅紧凑,便于快速查阅关键代码与接口说明。文档以HttpConnector30、SoapSerializer30和SoapReader30为主线,展示设置EndPointURL、建立Connect连接、指定SoapAction、构建Envelope与Body、写入cmpcode、phone、content等参数、调用EndMessage发送消息,以及从输出流读取RpcResult的完整调用链,并给出_com_error异常捕获与错误描述返回方式,对SOAP Connector属性、终结点地址和消息流之间的对应关系作出说明。已有179人学习,可作为VC++调用Web Service的入门样例和排错参照,帮助读者理解SOAP消息序列化、参数组织、命名空间设置与响应解析过程,也便于在此基础上改造为自有业务接口的集成代码。

1. 老 VC 接短信网关:为什么我仍会先写一条 SOAP 报文

老 VC 项目要接短信网关,常见需求不是重写架构,而是在一个 MFC 对话框里把验证码或通知发出去。VC 调用 WebService 的难点往往不在 C++ 语法,而在 SOAP 的 endpoint、SoapAction、命名空间和 XML 元素层级必须同时对。下面这份示例走的是 COM 组件路线:用 HttpConnector30 建连接,用 SoapSerializer30 拼消息,用 SoapReader30 读响应。适合维护存量桌面程序、又不想引入新运行时的人。

我见过不少调用代码,连接能建、消息能发,但服务端返回 500,最后查出来只是EndPointURL多带了?wsdl,或者SoapAction少了一个斜杠。这类问题不靠猜,靠把 WSDL 里的三样东西抄准,再用最小报文打一遍。

2. 先对齐协议再写代码:SOAP 1.1 调用链与 COM 组件选型

2.1 一次调用经过哪些对象

这类 COM 调用不是“把 URL 填进去就完事”。一次短信发送至少经过四步:连接器建立 HTTP 通道,序列化器往输入流写 XML,连接器把消息发出去,读取器从输出流解析 XML。对象之间的关系可以用下面这张表看清。

对象作用常见出错表现
HttpConnector30建立 HTTP 连接,设置 endpoint、SoapAction、认证和超时连接失败、405、500、超时
SoapSerializer30按 SOAP 结构写 Envelope、Body、元素和文本命名空间不对、元素层级不对、参数为空
SoapReader30读取响应流,取 RPC 结果或原始 Body返回空、乱码、解析不到字段
InputStream/OutputStream连接器与序列化器、读取器之间的数据通道忘了 EndMessage、读错流

常见做法是先把连接器创建出来,再设置EndPointURL和SoapAction。这两个属性一个决定“发到哪里”,一个决定“调用哪个操作”。很多人只改 URL,不改 Action,结果请求打到了网关,但服务端认为你要调用的方法不存在。

序列化器初始化时,要把它接到连接器的InputStream上。也就是说,序列化器写出来的 XML 不是写到文件,而是写到连接器准备发送的流里。消息写完后必须调用EndMessage(),这一步才是真正发送。读取器则相反,它从OutputStream里读服务端返回的内容。

2.2 为什么老项目偏爱 COM 组件而不是重写 HTTP 客户端

在存量 VC 工程里,引入一套新的网络库往往比写一个 SOAP 调用函数更麻烦。工程可能还在用旧版 MFC,编译链、运行库、部署目录都固定了,新增依赖意味着打包、注册、权限和兼容性都要重新过一遍。COM 组件的好处是跟着系统或组件包走,代码里用#import导入类型库后,就能用智能指针写调用,和现有 MFC 代码的CString、_bstr_t也容易接上。

但这条路有边界。COM 组件对命名空间和 XML 结构比较严格,错误信息有时只给一个_com_error描述,排查体验不算好;异步、并发和连接复用也要自己控制。我的建议是:如果只是维护老系统里的几个 SOAP 接口,用 COM 组件足够;如果是新项目、要接多个服务、要做连接池和重试,手工拼 XML 加 HTTP 请求,或者用成熟的跨平台 SOAP 库,反而更容易控制。

还有一个选型细节:不要因为“能连上”就认为协议对了。SOAP 1.1 的SoapAction是 HTTP 头的一部分,服务端可能先校验它,再解析 Body。Action 对了、Body 里的命名空间错了,仍然可能返回参数为空。下面这张对照表,是我一般在动手前先填的。

配置项示例决定什么
EndPointURLhttp://soap.example.test/cif/services/SMS请求真正发到哪个地址
SoapActionhttp://soap.example.test/cif/services/SMS/send服务端路由到哪个操作
目标命名空间http://soap.example.test/cif/services/SMSBody 里元素的 namespace
子元素命名空间空或同目标命名空间参数是否被服务端识别

这张表填不准,后面写多少代码都是碰运气。尤其是EndPointURL,它和 WSDL 地址不是一回事。WSDL 地址用来读服务描述,真正调用时通常不带?wsdl。

3. 环境与最小可运行:从类型库导入到一次成功调用

3.1 工程初始化与组件注册

先确认组件在本机可用。常见做法是用管理员权限注册组件,然后在工程里导入类型库。下面的命令只做示例,实际文件名和目录以你机器上的组件包为准。

regsvr32 mssoap30.dll

工程里加导入语句。no_namespace是为了让代码直接使用ISoapConnectorPtr、ISoapSerializerPtr这类类型,避免命名空间前缀干扰老代码。

#import "mssoap30.dll" no_namespace

MFC 程序一般在InitInstance里初始化 COM。用AfxOleInit()比手动CoInitialize更贴合 MFC 的生命周期;如果是控制台程序,就在主函数开头调用CoInitializeEx(NULL, COINIT_APARTMENTTHREADED),退出前CoUninitialize()。

BOOL CWebserviceApp::InitInstance() { if (!AfxOleInit()) { return FALSE; } CWinApp::InitInstance(); return TRUE; }

注意:如果调用放在工作线程里,线程自己也要初始化 COM。主线程初始化过,不代表子线程能用。

3.2 可编译的 BeginSoap 调用骨架

下面这段代码把连接、序列化、发送、读取串起来。地址和手机号都是占位值,不要直接拿去打真实网关。WebUrl参数留作外部传入,但我建议传业务 endpoint,不要传 WSDL 地址。

#import "mssoap30.dll" no_namespace static void AddTextElement(ISoapSerializerPtr& ser, const CString& name, const CString& value, const CString& ns) { // 第三个参数是前缀,第四个参数是属性串;没有就传空 ser->StartElement(_bstr_t(name), _bstr_t(ns), _bstr_t(""), _bstr_t("")); ser->WriteString(_bstr_t(value)); ser->EndElement(); } CString CWebserviceDlg::BeginSoap(CString UserName, CString Password, CString WebUrl) { CString endpoint = _T("http://soap.example.test/cif/services/SMS"); CString ns = _T("http://soap.example.test/cif/services/SMS"); CString action = ns + _T("/send"); if (!WebUrl.IsEmpty()) { endpoint = WebUrl; } try { ISoapConnectorPtr conn; conn.CreateInstance(__uuidof(HttpConnector30)); conn->Property["EndPointURL"] = _bstr_t(endpoint); conn->Property["SoapAction"] = _bstr_t(action); conn->Property["Timeout"] = 5000; // 组件支持时生效,单位毫秒 // 只有网关要求 HTTP 认证时才设置,不要把密码塞进 SOAP Body if (!UserName.IsEmpty()) { conn->Property["Username"] = _bstr_t(UserName); } if (!Password.IsEmpty()) { conn->Property["Password"] = _bstr_t(Password); } HRESULT hr = conn->Connect(); if (FAILED(hr)) { return _T(""); } conn->BeginMessage(); ISoapSerializerPtr ser; ser.CreateInstance(__uuidof(SoapSerializer30)); ser->Init(_variant_t((IUnknown*)conn->InputStream)); ser->StartEnvelope( _bstr_t("SOAP-ENV"), _bstr_t("http://schemas.xmlsoap.org/soap/envelope/"), _bstr_t("UTF-8")); ser->StartBody(_bstr_t("")); // send 是 WSDL 里的操作名,namespace 必须和 targetNamespace 对齐 ser->StartElement(_bstr_t("send"), _bstr_t(ns), _bstr_t(""), _bstr_t("")); // 子元素是否带 namespace,要看 WSDL 的 elementFormDefault AddTextElement(ser, _T("cmpcode"), _T("0001"), _T("")); AddTextElement(ser, _T("phone"), _T("00000000000"), _T("")); AddTextElement(ser, _T("content"), _T("测试短信"), _T("")); AddTextElement(ser, _T("receivedate"), _T(""), _T("")); ser->EndElement(); // send ser->EndBody(); ser->EndEnvelope(); // 真正发送 hr = conn->EndMessage(); if (FAILED(hr)) { return _T(""); } ISoapReaderPtr reader; reader.CreateInstance(__uuidof(SoapReader30)); reader->Load(_variant_t((IUnknown*)conn->OutputStream), _bstr_t("")); if (reader->RpcResult != NULL) { _bstr_t bstr = reader->RpcResult->text; return CString((LPCTSTR)bstr); } return _T(""); } catch (_com_error& e) { return CString((LPCTSTR)e.Description()); } }

这段代码里,endpoint决定请求发到哪里,ns决定 XML 元素的命名空间,action决定服务端按哪个操作路由。cmpcode、phone、content、receivedate是短信接口的参数名,必须和 WSDL 里的元素名一致。receivedate在示例里是空字符串,有些网关接受空元素,有些要求直接省略,联调时要对着接口文档确认。

AddTextElement里第三个参数是命名空间前缀。示例传空,表示使用默认命名空间或不带前缀。第四个参数是属性串,没有属性就传空。不要在这里手写xmlns,序列化器会根据StartElement的参数生成命名空间声明。

Reader->RpcResult适合返回简单字符串的接口。如果服务端返回的是结构体,RpcResult可能拿不到完整内容,这时候要读OutputStream里的原始 XML,或者按 WSDL 的返回结构逐层取节点。不要看到返回空就怀疑网络,先确认返回类型。

4. 消息体怎么拼:命名空间、SoapAction 与参数映射

4.1 从 WSDL 抄三样东西:endpoint、action、namespace

打开 WSDL 后,不要从头读到尾。先找service里的address location,这是 endpoint;再找binding里的soap:operation soapAction,这是 Action;最后找definitions上的targetNamespace,这是命名空间。子元素要不要带命名空间,看schema上的elementFormDefault。

<!-- 只示意要抄的位置,真实 WSDL 结构可能不同 --> <definitions targetNamespace="http://soap.example.test/cif/services/SMS"> <binding name="SMSBinding" type="tns:SMSPortType"> <soap:binding transport="http://schemas.xmlsoap.org/soap/http"/> <operation name="send"> <soap:operation soapAction="http://soap.example.test/cif/services/SMS/send"/> </operation> </binding> <service name="SMSService"> <port name="SMSPort" binding="tns:SMSBinding"> <soap:address location="http://soap.example.test/cif/services/SMS"/> </port> </service> </definitions>

提示:有些 WSDL 的soapAction带引号,有些不带。组件一般会自己处理 HTTP 头格式,你只要填 Action 值本身,不要把外层引号也拼进去。

4.2 用 SoapSerializer 构造 send 报文

序列化器写出来的结构,应该和下面这段 XML 对得上。注意Envelope的命名空间是 SOAP 规范地址,send的命名空间是业务目标命名空间,两者不要混。

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"> <SOAP-ENV:Body> <send xmlns="http://soap.example.test/cif/services/SMS"> <cmpcode>0001</cmpcode> <phone>00000000000</phone> <content>测试短信</content> <receivedate></receivedate> </send> </SOAP-ENV:Body> </SOAP-ENV:Envelope>

对应的序列化代码就是前面那段:

ser->StartEnvelope( _bstr_t("SOAP-ENV"), _bstr_t("http://schemas.xmlsoap.org/soap/envelope/"), _bstr_t("UTF-8")); ser->StartBody(_bstr_t("")); ser->StartElement(_bstr_t("send"), _bstr_t(ns), _bstr_t(""), _bstr_t("")); AddTextElement(ser, _T("cmpcode"), _T("0001"), _T("")); AddTextElement(ser, _T("phone"), _T("00000000000"), _T("")); AddTextElement(ser, _T("content"), _T("测试短信"), _T("")); AddTextElement(ser, _T("receivedate"), _T(""), _T("")); ser->EndElement(); ser->EndBody(); ser->EndEnvelope();

如果 WSDL 的elementFormDefault是qualified,子元素也要带命名空间,AddTextElement的第四个参数就不能传空,要传ns。这时候生成的 XML 里,子元素会带xmlns或前缀。服务端如果按 qualified 校验,子元素不带命名空间就会被当成另一个元素,表现出来就是参数为空或反序列化失败。

StartElement的第二个参数是命名空间 URI,不是 WSDL 地址。不要把http://soap.example.test/cif/services/SMS?wsdl填进去。?wsdl是给浏览器和工具读服务描述用的,不是 XML 元素的命名空间。

4.3 读响应与 BSTR 转换

响应读取常见两种写法。返回简单字符串时,直接取RpcResult->text;返回结构体时,先保存原始 XML,再按节点解析。下面这段是简单返回的写法:

ISoapReaderPtr reader; reader.CreateInstance(__uuidof(SoapReader30)); reader->Load(_variant_t((IUnknown*)conn->OutputStream), _bstr_t("")); if (reader->RpcResult != NULL) { _bstr_t bstr = reader->RpcResult->text; CString result((LPCTSTR)bstr); return result; } return _T("");

RpcResult->text是 BSTR,不要直接强转成char*。在 Unicode 工程里,char*强转会把宽字符截断,轻则乱码,重则崩溃。用_bstr_t接住,再转CString,让编译器按工程字符集处理。如果你的工程是 MBCS,也要确认_bstr_t的转换宏和CString构造方式一致,不要混用CStringA和CStringW。

如果返回值不是简单类型,或者RpcResult为空,先把OutputStream的内容写到日志文件里。看到原始 XML,才能判断是服务端返回了错误结构,还是读取器取错了节点。

5. 避坑与排查:五个高概率翻车点

5.1 把 ?wsdl 写进 EndPointURL

现象:浏览器能打开 WSDL,代码一调用就返回 405 或 500,服务端日志里看不到正常的 SOAP 操作。原因:EndPointURL填成了http://soap.example.test/cif/services/SMS?wsdl,这个地址是服务描述地址,不是 SOAP 请求地址。解决:把?wsdl去掉,只保留服务 endpoint;WSDL 地址单独放在配置或文档里,用来核对 Action 和命名空间。

5.2 SoapAction 少了斜杠或多引号

现象:连接成功,Body 也发出去了,服务端返回“操作不存在”或“Action 不匹配”。原因:SoapAction和 WSDL 里的值不完全一致,常见的是命名空间末尾少一个斜杠,或者把 WSDL 里的外层引号也拼进了字符串。解决:从 WSDL 的soapAction属性原样复制,注意目标命名空间和操作名之间的分隔符。如果 WSDL 写的是.../SMS/send,就不要写成.../SMSsend。

5.3 子元素命名空间套错

现象:服务端返回参数为空,或者提示某个字段不能为空,但你的代码明明写了值。原因:父元素send带了业务命名空间,子元素却带了 WSDL 地址,或者该带命名空间时没带。解决:看 WSDL 的elementFormDefault,确认子元素是 qualified 还是 unqualified。用序列化器生成 XML 后,把请求报文打到日志里,和 WSDL 示例逐层比对。

5.4 BSTR 强转 char* 导致乱码

现象:返回结果里中文变成问号,或者程序在CString构造时崩掉。原因:RpcResult->text是 BSTR,内部是宽字符,直接(const char*)强转不会做编码转换。解决:用_bstr_t接住,再通过CString((LPCTSTR)bstr)转换;同时确认工程字符集和接口编码一致。请求里的中文也要确认序列化器按 UTF-8 输出,不要手工拼接未转义的 XML。

5.5 只 Connect 不 EndMessage,或线程里没初始化 COM

现象:代码执行完没有任何返回,服务端也没收到请求;或者在工作线程里调用时直接抛_com_error。原因:BeginMessage只是准备消息,EndMessage才发送;工作线程没有初始化 COM,智能指针创建失败。解决:检查Connect、BeginMessage、EndMessage是否成对出现;工作线程入口调用CoInitializeEx,退出时CoUninitialize。如果调用频率高,不要把连接器做成全局单例跨线程复用。

6. 进阶与验证:把短信调用封成可回归的接口

我一般把这类接口封成三层:协议层只负责 endpoint、SoapAction、命名空间和报文结构;业务层只接收手机号、内容、流水号;验证层保存请求 XML 和响应 XML。这样改 WSDL 时,只需要动协议层,业务代码不用跟着翻。

每次联调先跑固定样例,不要一上来就接真实业务参数。验证顺序可以按下面这张表走。

验证项做法判据
endpoint用浏览器打开 WSDL,确认address location和代码里的 endpoint 一致,且不带?wsdl
Action从 WSDL 复制soapAction和代码里的action完全一致
报文记录序列化后的 XML父元素、子元素命名空间和 WSDL 对齐
返回保存原始响应RpcResult能取到值,或能按结构体解析

还有两个细节容易被忽略。第一,短信发送不是天然幂等的,超时后不要盲目重试,否则可能重复发短信;要重试就带业务流水号,让服务端去重。第二,Timeout不要设得太短,老网关首次连接可能比较慢,先给 5 秒,再根据日志调整。

从那以后我每次接老 SOAP 接口,都先把 WSDL 里的 endpoint、action、namespace 抄到一张表里,再用最小报文打通,最后才接业务参数。这份示例代码和报文模板可以按资源入口获取,拿去对着自己的 WSDL 改三处就能跑。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进