多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

VC++老系统手写SOAP报文调用WebService短信接口全流程

VC++老系统手写SOAP报文调用WebService短信接口全流程 简介一份面向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 动手前先读 WSDLtargetNamespace、方法名、参数顺序都是坑源拿到一个 WebService 地址第一步不是写代码而是打开?wsdl把文档读明白。资源里的代码写得比较紧凑但里面有一个反复出现的字符串http://服务IP:端口/cif/services/SMS它同时出现在 EndPointURL、命名空间和 StartElement 参数里这就是没把 WSDL 里的概念拆开。WSDL 文件里有几个东西必须分清楚。EndPointURL是服务实际接收请求的物理地址通常是一个 URLtargetNamespace是这套接口的业务命名空间用来标识方法归属soapAction是服务端用来路由请求的标识方法参数顺序则由 message/part 或 schema 的 sequence 定义。一份典型 WSDL 片段长这样注意看 targetNamespace 和访问地址的区别wsdl:definitions targetNamespacehttp://server:port/cif/services/SMS wsdl:message namesendRequest wsdl:part namecmpcode typexsd:string/ wsdl:part namephone typexsd:string/ wsdl:part namecontent typexsd:string/ wsdl:part namereceivedate typexsd:string/ /wsdl:message wsdl:operation namesend soap:operation soapActionhttp://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则是该节点下的文本内容。对于短信网关这类方法通常服务端返回的是一个布尔值、一个编号或一段状态描述。如果服务端返回的结构是嵌套 XMLtext拿到的只是最底层文本节点之外的内容会被丢弃。资源里的代码用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 以后默认字符集是 UnicodeCString内部是宽字符。资源里的CString((const char*)Reader-RpcResult-text)在 Unicode 工程下会把 BSTR 窄化字符集转换没做好中文自然全丢。发送端也一样WriteString期望的是 BSTR你传入窄字符串编码就错了。解决发短信前统一用CW2A把CString转成 UTF-8 或 GBK 字节流取决于服务端解析方式响应回来用CString(Reader-RpcResult-text)让编译器走 BSTR 重载。如果工程老代码太多改不动至少把BeginSoap内部单独处理字符集不要在函数边界混用窄宽字符。实际排查时可以先发一条纯英文短信验证通道再测中文这样能区分是编码问题还是通道问题。4.3 忘了 CoInitialize / AfxOleInitCreateInstance 直接报 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, hr0x%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再调探测方法最后才动业务参数。无论是维护老系统还是接新通道这套流程都能帮你快速落地。希望帮到你。本文还有配套的精品资源点击获取
返回列表