简介一份面向VC开发者的Web Service调用示例文档演示如何借助COM组件与SOAP协议完成远程短信服务的对接。文档给出完整可运行的BeginSoap函数代码覆盖HttpConnector30连接器创建、EndPointURL终结点设置、SoapSerializer序列化构造SOAP消息、参数封装与EndMessage发送等关键流程并配有逐段注释与说明适合需要快速理解或移植SOAP调用逻辑的中级C程序员。资源为单个doc文档体积仅28KB便于离线查阅与代码复制。已有179人学习下载可帮助读者理清VC环境下调用Web Service的通信链路与COM接口用法避开因命名空间或消息结构拼写而导致的常见错误。1. 老VC项目调 Web Service一段前辈留下的 SOAP 调用代码今天还能跑在 VC 编程programming里调 Web Service最容易卡住的不是语法而是 SOAP 这层黑匣子连接器建好了消息也发出去了服务端就是不按预期响应。这份资源里是一段真实跑通过的老代码借助微软 SOAP Toolkit 3.0 的 HttpConnector30 连接器向一个短信网关 Web Service 发送 SOAP 请求完成一次短信下发。它解决的问题有两个本地 MFC 程序怎么用 SoapSerializer 拼出标准 SOAP Envelope以及怎么把 cmpcode、phone、content 这类业务参数挂进 Body 再拿回服务端返回。适合接手老 VC 项目、需要读懂或移植这类 SOAP 调用代码的开发者。新手可以按本文顺序照抄跑通熟手可以直接带走参数边界和后面那五个翻车点。2. HttpConnector30 连接器类型库引入、端点配置与 Connect 失败排查这段代码的核心依赖是 SOAP Toolkit 3.0一套 VS2005/2008 时代的老组件。它暴露三个 COM 对象HttpConnector30 负责 HTTP 传输SoapSerializer30 负责构造 SOAP 消息SoapReader30 负责解析响应。动手写代码之前得先让编译器认这三个组件的 uuid。2.1 COM 初始化与 mssoap30 类型库引入先确认本机装了 SOAP Toolkit 3.0。装完后通常能在C:\Program Files (x86)\Common Files\MSSoap\Binaries\mssoap30.dll找到组件。工程里导入类型库的方式是#import不是#include// 放在 stdafx.h 或使用该组件的 cpp 顶部 #import C:\\Program Files (x86)\\Common Files\\MSSoap\\Binaries\\mssoap30.dll no_namespaceno_namespace让 HttpConnector30、SoapSerializer30 这些类型直接可见不用再写长长的命名空间前缀。#import会在编译时生成 tlh/tli 两个文件__uuidof(HttpConnector30)的定义就是从这两个文件里来的。注意别把#include mssoap.h和它混用那是旧版 SDK 的头文件和 Toolkit 3.0 的类型库重复定义后会冲突。COM 环境的初始化在 MFC 工程里要单独确认。大多数 MFC 框架已经在CWinApp::InitInstance里调用了AfxOleInit()但老工程不一定有稳妥做法是在对话框初始化时自己补一次// 对话框初始化处 HRESULT hr CoInitialize(NULL); if (FAILED(hr) hr ! RPC_E_CHANGED_MODE) { AfxMessageBox(_T(COM 环境初始化失败)); return FALSE; }这里hr S_FALSE不是错误说明 COM 之前已经被初始化过继续用即可。RPC_E_CHANGED_MODE表示当前线程模型和已有设置冲突比如 MFC 已经以 Apartment 模式初始化这里再请求 Free 模式就会报这个。遇到它直接删掉这行让框架的AfxOleInit()统一管理。还有一个很常见的问题SOAP Toolkit 3.0 的 mssoap30.dll 只有 32 位版本没有 64 位实现。把工程编译成 x64 再运行会在CreateInstance那一步直接抛出class not registered。解决方案是让工程保持 x86 目标平台。如果开发机系统是 64 位注册组件时要手动指定 32 位 DLL 路径regsvr32 C:\Program Files (x86)\Common Files\MSSoap\Binaries\mssoap30.dll我接手过一份老代码对方只在交接说明里写了装了 SOAP Toolkit 就能跑结果新同事的 64 位开发机上怎么都报错。最后把项目平台从 x64 切回 x86再注册一次 DLL问题才消失。发布到生产机时同样要处理这一步条件允许的话把注册动作放进安装包。2.2 EndPointURL 与 SoapAction 从哪里来代码里给EndPointURL赋的值是http://211.95.160.38:80/cif/services/SMS?wsdl这个地址其实分两段。?wsdl之前的部分是服务真正接收 SOAP 消息的 HTTP 端点?wsdl只是给浏览器看服务描述用的。把?wsdl写进EndPointURL的后果是HTTP POST 打到了服务描述页上而不是 SOAP 处理器上。有些服务端能容错有些直接返回 WSDL 文本当成响应。正确做法是打开 WSDL 页面找到soap:address的location属性。一个典型的 WSDL 服务节点长这样wsdl:definitions targetNamespacehttp://211.95.160.38/cif/services/SMS wsdl:service nameSMS wsdl:port nameSMSSoap bindingtns:SMSSoap soap:address locationhttp://211.95.160.38/cif/services/SMS/ /wsdl:port /wsdl:service /wsdl:definitionslocation的值才是EndPointURL该传的东西末尾不要带?wsdl。同理SoapAction也不是靠命名空间 方法名拼出来的WSDL 里每个 operation 上都写清楚了 HTTP 头要用的 soapAction 值wsdl:operation namesend soapActionhttp://211.95.160.38/cif/services/SMS/send/原文代码用拼接方式也能跑是因为这个网关的服务端对 SoapAction 做了宽松匹配。换成严格服务端拼出来的值和 WSDL 声明不一致返回的就是Invalid SOAPAction。我的习惯是写代码前先用浏览器打开 WSDL把targetNamespace、soapAction、location三个值原样复制到工程里不做任何字符串运算。2.3 Connect() 返回失败时的排查顺序Connect()失败时返回FAILED(hr)原代码直接return 这个处理会让上层连到底哪里错了都看不到。第一步先把它修掉把 HRESULT 转成可读文本if (FAILED(hr)) { _com_error err(hr); return (CString)(_bstr_t)err.ErrorMessage(); }_com_error::ErrorMessage()返回的是 BSTR 描述用_bstr_t桥转后再赋给 CString避免后面细说的 BSTR 强转截断问题。这样你能看到是目标主机拒绝连接还是服务器返回 503。接下来按顺序排查网络层问题。第一确认本机能直连目标 IP 的 80 端口直接在浏览器地址栏输入http://211.95.160.38:80/cif/services/SMS?wsdl能打开说明 TCP 通。第二确认 WSDL 页面里的 location 值能从当前网络环境访问很多短信网关只对特定来源 IP 开放不在白名单里的请求在 HTTP 层就会被断开表现为 Connect 失败或 EndMessage 超时。第三确认本机防火墙的出站规则没有拦截 80 端口。我遇到过一台新装的 Windows本机防火墙默认拦截外部 HTTP 请求关闭出站限制后一切正常。还有一点容易误判Connect()成功不代表配置对。它只完成 TCP 连接和基本的 HTTP 握手如果 EndPointURL 填成了?wsdl地址Connect 照样成功问题要等EndMessage()拿到一堆 WSDL 文本时才会暴露。所以排查时把Connect 失败和Connect 成功但响应结构不对分开看前者查网络后者查地址配置。3. SoapSerializer 构造 XML 消息Envelope/Body/Element 的嵌套规则与参数编码SoapSerializer30 的职责是帮你生成合法的 SOAP XML避免手拼字符串时漏转义、少闭合。它的内部结构是栈式构造StartXxx压栈EndXxx弹栈顺序错了 XML 结构就坏了。3.1 Serializer 的四个嵌套方法对应 SOAP 的哪四层从原文代码里挑出核心骨架完整的 SOAP 消息构造路径是Envelope → Body → 业务元素 → 业务元素的子元素 → 文本内容然后反向闭合。对应到代码// 将序列化器连接到连接器的输入流 Serializer-Init(_variant_t((IUnknown*)SoapConnector-InputStream)); // 第 1 层SOAP Envelope指定前缀、命名空间、编码 Serializer-StartEnvelope(LSOAP-ENV, L, LUTF-8); // 第 2 层Body Serializer-StartBody(); // 第 3 层业务方法 send Serializer-StartElement(send, Lhttp://211.95.160.38:80/cif/services/SMS, NONE, ); // 第 4 层send 下的参数 cmpcode Serializer-StartElement(cmpcode, Lhttp://211.95.160.38:80/cif/services/SMS, NONE, ); Serializer-WriteString(0166); Serializer-EndElement(); // 闭合 cmpcode Serializer-EndElement(); // 闭合 send Serializer-EndBody(); Serializer-EndEnvelope();这段代码最终生成的 XML 结构长这样?xml version1.0 encodingUTF-8? SOAP-ENV:Envelope xmlns:SOAP-ENVhttp://schemas.xmlsoap.org/soap/envelope/ SOAP-ENV:Body send xmlnshttp://211.95.160.38:80/cif/services/SMS cmpcode0166/cmpcode /send /SOAP-ENV:Body /SOAP-ENV:EnvelopeStartEnvelope的第一个参数是命名空间前缀第二个参数传空字符串表示使用协议默认的 SOAP 命名空间第三个参数UTF-8会写进 XML 声明的 encoding 属性。StartElement有四个参数元素名、命名空间 URI、前缀策略、属性字符串。这里传入的NONE是 SoapSerializer 的约定写法表示这个元素使用默认的命名空间且不强制加前缀不是真的往文档里写一个叫 NONE 的前缀。WriteString写的是元素文本内容写完必须用EndElement和它配对。流水线顺序很重要Serializer-Init要在StartEnvelope之前它把序列化器绑定到连接器的输入流上后面生成的 XML 字节都写进这个流。实际开发中经常有人把 Init 漏掉运行时会报对象未初始化。3.2 命名空间传参的坑wsdl 地址不等于 targetNamespace原文代码里StartElement的第二参数统一传的是http://211.95.160.38:80/cif/services/SMS?wsdl。问题在于命名空间是一个 URI用来在 XML 里标识元素所属的词汇表它不应该是带查询字符串的地址。把?wsdl写进命名空间生成的 XML 里每个业务元素都会带一个奇怪的xmlnshttp://…SMS?wsdl。服务端拿到消息后会拿这个值和 WSDL 里声明的targetNamespace逐字符比对匹配不上就返回类似unable to find operation的错误。正确值在 WSDL 根元素上wsdl:definitions targetNamespacehttp://211.95.160.38/cif/services/SMS xmlns:soaphttp://schemas.xmlsoap.org/wsdl/soap/代码里应该用targetNamespace的属性值也就是不带?wsdl的那部分。这是 SOAP 客户端最常见的照抄翻车点因为浏览器地址栏里的?wsdl太显眼一顺手就复制进去了。严格模式下它会直接让服务端找不到操作宽松模式下却被忽略所以很多老代码这么写也能跑遇到不跑的就抓包先比对命名空间。这类问题另一个隐蔽变体是标签名里带空格。原文代码里StartElement(cmpcode )这类写法引号内元素名后面跟了一个空格生成的 XML 是cmpcode 。XML 解析器对元素名的空白非常敏感服务端会直接报告invalid element name。我照抄这段代码时就在这里翻过车服务端返回 SOAP Fault抓包才看到标签名是cmpcode前前后后折腾了半小时。拿到这类老代码第一步就是把所有标签名两端的空格清干净。3.3 参数内容编码中文字符和空节点怎么处理短信内容WriteString(你好)在大多数中文 Windows 的 ANSI 工程里能正常跑通但有一个前提源文件的编码和编译器认知一致。如果工程字符集是多字节字符集而源文件被保存成了 UTF-8 with BOM那么你好在内存里是 UTF-8 字节串WriteString会按 GB2312 去解码转出来就是乱码。解决方法是显式转换不让 WriteString 猜#include atlconv.h CString strContent _T(你好); // 来自输入框或参数 CA2W wContent(strContent, CP_ACP); // 按当前 ANSI 代码页转成宽字符 Serializer-WriteString((BSTR)wContent); // 使用 BSTR 重载CA2W的第二个参数指定源字符串代码页。CP_ACP表示 ANSI 代码页中文 Windows 上就是 GB2312如果内存里已经是 UTF-8 编码就改成CP_UTF8。我一般统一用CP_ACP前提是源文件保持 ANSI 保存这样从源文件到内存再到 SOAP 消息整条链路的编码路径是单一的不会出现二次编码问题。还有一个不起眼的细节receivedate节点原文写的是WriteString( )注意那是一个空格字符不是空字符串。服务端收到的是 而不是对要求严格的数据库字段来说一个是有效值一个是空值。要传空内容应该写WriteString(L)如果服务端本身不要求该节点干脆省略不写。4. 发送消息与读取响应EndMessage 后如何安全地拿到返回结果SOAP 请求构造完下一步是发出去并读取响应。这一段有三个问题值得较真EndMessage()会阻塞多久、响应内容怎么安全取回来、以及返回值要怎么设计才能让上层区分错误类型。4.1 EndMessage() 的阻塞行为与线程化改造EndMessage()不是异步发消息它会同步等待 HTTP 响应返回。短信网关在公网一次调用少说几百毫秒网关排队时可能拖到几十秒。把这段逻辑直接放在 UI 线程里用户点一次发送按钮窗口就假死一次。老项目里常见的改法是开工作线程配超时等待UINT SoapSendThread(LPVOID pParam) { CWebservice_vcDlg* pDlg (CWebservice_vcDlg*)pParam; // 线程里必须重新初始化 COM CoInitialize(NULL); CString strResp pDlg-BeginSoap(user, pwd, url); pDlg-m_strResp strResp; // 结果存回成员变量 CoUninitialize(); return 0; } // 调用处 HANDLE hThread CreateThread(NULL, 0, SoapSendThread, pDlg, 0, NULL); if (hThread ! NULL) { WaitForSingleObject(hThread, 15000); // 15 秒等到底 CloseHandle(hThread); }WaitForSingleObject的第二个参数是超时毫秒数正常网关在 13 秒内返回超过 15 秒基本可以判定网络或服务端异常。超时后线程还没退出需要在回调里做收尾标记示例简化了这块实际项目里可以加一个BOOL m_bTimeout标志线程结束后检查它决定是否提示重试。注意线程函数里必须自己调用CoInitialize(NULL)。BeginSoap 内部会创建多个 COM 对象它们依赖线程的 COM 环境。我有一次忘了加这行线程里一执行到CreateInstance就抛COM 未初始化异常单独调试还看不出来因为主线程的 COM 环境已经就绪了。4.2 Reader-RpcResult 的判空与 SOAP Fault 识别原代码读取响应那一行是整段里最危险的地方str CString((const char*)Reader-RpcResult-text);RpcResult-text返回的是 BSTR也就是宽字符指针。这里被强转成const char*CString 按 ANSI 逐字节读遇到宽字符里的\0就直接截断能显示出来的内容要么是半个字符要么是乱码。更严重的是服务端返回 SOAP Fault 时RpcResult是 NULL直接取-text就是解引用空指针当场崩溃。安全写法是先判断faultstring再判空RpcResult然后用_bstr_t桥转// 先识别 SOAP Fault if (Reader-faultstring ! NULL SysStringLen(Reader-faultstring) 0) { return _T(SOAP Fault: ) (CString)(_bstr_t)Reader-faultstring; } // 再判空 RpcResult if (Reader-RpcResult NULL) { return _T(响应为空请检查命名空间和请求结构); } // 用 _bstr_t 做类型转换避免 BSTR 截断 _bstr_t bstrText Reader-RpcResult-text; return (CString)bstrText;faultstring是 SOAP Fault 报文里的标准字段服务端在处理业务逻辑之前报错比如参数缺失、命名空间不匹配都会把错误描述放在这里。先判断它能拿到比响应为空更精确的提示。Reader-Load的第二个参数原代码传的是_T()它表示给响应文档指定一个基础 URL空着就行别传相对路径。4.3 返回值的错误处理设计原文的catch (_com_error e)块里写的是return (CString)(char*)e.Description()。Description()同样是 BSTR这里的(char*)强转有和上面一样的截断问题顺手改掉catch (_com_error e) { return _T(错误码: ) (CString)(_bstr_t)e.Description(); }改完这一步BeginSoap 的返回值就可以约定成三种形态成功时是服务端返回的文本业务参数错误时是SOAP Fault:前缀的字符串网络或 COM 异常时是错误码:前缀的字符串。调用方按前缀分流短信业务最怕重复下发这样能区分可以重试和不能重试CString strResp pDlg-BeginSoap(strUser, strPwd, strUrl); if (strResp.Find(_T(SOAP Fault:)) 0) { // 业务参数错误提示用户不要自动重发 } else if (strResp.Find(_T(错误码:)) 0) { // 网络或连接失败可稍后重试 } else { // 成功更新界面 }5. 避坑排查五个真实翻车点从照抄到跑通的距离这部分是接手这类老代码时一定会撞上的几个问题我按报文构造层和运行结果层拆开讲。每一条都是实际运行中见过的现象原因和解决路径一起给出。5.1 报文构造层EndPointURL、SoapAction 与标签名空格现象Connect 正常EndMessage 也正常但 RpcResult 是 NULL抓包发现服务端返回的是一整段 WSDL XML 文本而不是 SOAP 响应。原因EndPointURL 填了带?wsdl的地址。HTTP POST 打到了服务描述页上描述的 GET 和 POST 共用同一个 URL但服务端对 POST 的处理逻辑不一样这里直接返回了 WSDL 原文件。解决把 EndPointURL 改成 WSDL 里soap:address location的值不带?wsdl。改完再抓包看到返回的是 SOAP Envelope 才说明打对了地方。现象HTTP 返回 500服务端日志出现Invalid SOAPAction。原因命名空间字符串末尾有两个看不见的空格。原文定义的szNameSpace末尾是SMS/拼出的 SoapAction 变成http://…SMS/ sendHTTP 头里带着空格严格服务端直接拒绝。解决拼接 SoapAction 之前先做一次尾部清理CString strNS _T(http://211.95.160.38:80/cif/services/SMS/); strNS.TrimRight(_T( )); // 去掉尾部空格保留末尾斜杠或者直接放弃字符串拼接从 WSDL 的soapAction属性里原样复制。现象服务端返回XML 解析错误元素名不合法抓包看到cmpcode 。原因老代码里StartElement的元素名引号内带了空格比如cmpcode 和phone 。这些空格肉眼很难发现复制到代码里就成了 XML 的非法 element name。解决拿到资源后先在编辑器里做一次全局搜索匹配模式是后紧跟字母、字母后跟空格、再跟把所有标签名内部空格清掉。这类问题在编码格式上完全合法只能靠细心排查。5.2 运行结果层中文乱码与 BSTR 截断现象短信发出去收到内容变成乱码常见的是锟斤拷或一串问号。原因源文件保存为 UTF-8但工程字符集是 ANSIWriteString(你好)传入的是 UTF-8 字节串Serializer 按 GB2312 解码再按 UTF-8 编码写进 XML双重转换后内容就废了。这个问题只在中文内容上暴露英文数字都正常所以定位起来非常迷惑。解决统一编码路径。源文件保存成 ANSIGB2312代码里用CA2W(strContent, CP_ACP)转成宽字符后传给WriteString(BSTR)重载。工程有条件的话切到 Unicode 字符集更省心但老工程依赖太多不建议为了这个函数大改。现象返回的字符串只有一个字符或者是乱码偶尔直接崩溃。原因CString((const char*)Reader-RpcResult-text)把 BSTR 强转成const char*CString 按字节读遇到宽字符的高字节\0就截断了。加上 RpcResult 为 NULL 时没有判空取 text 直接崩。解决按第 4.2 节的写法先用_bstr_t桥转再判空 RpcResult先看 faultstring。凡是遇到返回值只有一个字符这种症状优先怀疑 BSTR 强转这个坑在 COM 编程里藏得特别深。6. 进阶用法把 BeginSoap 改造成通用短信发送函数前面的流程把一个方法跑通之后你会发现整个 BeginSoap 里只有业务参数在变化。可以做一个最小化的参数化改造保留原函数签名把拼接 XML 节点的部分抽出来再补一个 XML 转义函数。短信内容来自用户输入框很可能会带、、这类特殊字符。直接WriteString它们Serializer 不会自动帮你转义生成的 XML 节点会提前闭合服务端解析直接失败。所以拼接业务参数时要先转义CString EscapeXml(const CString strIn) { CString strOut strIn; strOut.Replace(_T(), _T(amp;)); strOut.Replace(_T(), _T(lt;)); strOut.Replace(_T(), _T(gt;)); return strOut; }转义的顺序不能乱必须先替换再替换和。反过来写会把lt;里的再转一次生成amp;lt;服务端拿到后还要多解一层遇到不一致的解码实现就出问题了。参数化的思路是把 cmpcode、phone、content 这三个固定节点收敛成一个传入参数让 BeginSoap 只关心拿到三个字符串拼一个 send 请求发出去取回响应。我一般会在这一层把 SoapAction 和命名空间从硬编码改成成员变量在对话框初始化时从 WSDL 复制进来这样换网关时不用翻代码找字符串。另外补两个验证习惯。第一每次改 EndPointURL 和命名空间先在浏览器里打开 WSDL核对targetNamespace和soapAction与代码一致再跑程序。第二连续发送短信要注意节流多数网关对同一来源 IP 有速率限制一秒一条比较稳并发超过阈值会被网关拉黑一段时间。测试时我习惯写一个最小验证函数只调用 Connect 和 EndMessage把响应体完整输出到日志文件确认报文结构对了再往上加业务逻辑省去一大半调试时间。从那以后我每次接 SOAP 调用都会强制走一遍核对顺序先 WSDL 后代码先抓包后调试先判空再取值。这套顺序救过我很多次希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?