VC++调用WebService:SOAP Toolkit报文与空串排查 简介面向使用 VC 集成远程服务的开发者这份文档围绕 COM 组件与 SOAP 协议调用 Web Service 给出可运行的入门示例重点解决连接建立、操作指定、消息构造和响应读取等联调问题适合具备一定 C 基础、需要把本地程序接入远端接口的初中级开发者参考。压缩包内共 1 个 doc 文件约 28KB内容集中在一份代码说明中便于快速查阅和移植。示例以 HttpConnector30、SoapSerializer30 和 SoapReader30 为主线演示 EndPointURL 设置、Connect 连接、SoapAction 定义、Envelope、Body 与 Element 的组装、参数写入、EndMessage 发送以及 RpcResult 结果读取并带有 _com_error 异常处理。对于刚接触 COM 与 SOAP 的读者可把示例当作接口联调前的对照模板逐段核对地址、动作、消息体和返回流降低从零摸索的成本也可据此理解短信类 Web Service 的调用链排查连接失败、动作不匹配和结果解析等问题并按现有代码改造成自身业务接口。已有 179 人学习。1. VC 调 WebService 这件事一段总返回空串的代码为什么还在生产里跑接手一个维护多年的工控上位机工程时最费时间的往往不是界面而是一个只返回空字符串的短信发送函数。VC 调用 WebService 在 .NET 时代看着像老古董但在维护型 MFC 项目里它确实还活着没有 HttpClient没有 WCF手里只有 SOAP Toolkit 那几个 COM 组件。这次拆的是一段完整的CWebservice_vcDlg::BeginSoap实现——用HttpConnector30建连接、SoapSerializer30拼报文、SoapReader30取结果把用户名、密码、服务地址和一串业务参数送出去。它适合两类人手里有 VC6 或 VS2010 老工程、必须给现有 MFC 程序补一个远程调用口的以及第一次读 SOAP Toolkit 代码、被__uuidof、Property[LSoapAction]、StartElement四参数绕晕的。下文按能复现的颗粒度拆参数怎么填、命名空间怎么对齐、返回空串去哪儿查都落到具体位置。2. 拆开四段式调用建连、拼报文、发送、读结果的完整代码把原文那段挤在一起的代码重排一遍逻辑就清楚多了。整个过程是四段Connector 建连 → Serializer 往输入流写 SOAP 报文 → EndMessage 真正发出 → Reader 从输出流读回结果。下面这份是可编译的整理版服务地址和命名空间用占位符代替实际替换成自己的。// pch.h 或 stdafx.h导入 SOAP Toolkit 3.0 的类型库 // 路径按机器实际安装位置改默认在 Common Files\MSSoap\Binaries 下 #import mssoap30.dll named_guids, no_namespace CString CWebserviceDlg::BeginSoap(CString UserName, CString Password, CString WebUrl) { HRESULT hr S_OK; ISoapConnectorPtr SoapConnector; ISoapSerializerPtr Serializer; ISoapReaderPtr Reader; // 服务的 targetNamespace结尾不带 ?wsdl也不带多余空格 CString strNs _T(http://svc-host:8080/cif/services/SMS); try { // 第一段建连接器HttpConnector30 是 HTTP 传输实现 SoapConnector.CreateInstance(__uuidof(HttpConnector30)); SoapConnector-Property[LEndPointURL] (_bstr_t)WebUrl; // 只写服务地址 SoapConnector-Property[LTimeout] _variant_t((long)15); // 秒0 为不超时 hr SoapConnector-Connect(); if (FAILED(hr)) return _T(E_CONNECT); // SoapAction 命名空间 方法名注意后面没有斜杠 SoapConnector-Property[LSoapAction] (_bstr_t)(strNs _T(send)); SoapConnector-BeginMessage(); // 第二段构造 SOAP 报文写进 Connector 的输入流 Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer-Init(_variant_t((IUnknown*)SoapConnector-InputStream)); Serializer-StartEnvelope(LSOAP-ENV, Lhttp://schemas.xmlsoap.org/soap/envelope/, LUTF-8); Serializer-StartBody(L); // 方法名与参数都必须挂同一个命名空间不能只挂一半 Serializer-StartElement(Lsend, (_bstr_t)strNs, L, L); Serializer-StartElement(Lcmpcode, (_bstr_t)strNs, L, L); Serializer-WriteString(L0166); // 企业代码 Serializer-EndElement(); Serializer-StartElement(Lphone, (_bstr_t)strNs, L, L); Serializer-WriteString(L13200000000); // 手机号 Serializer-EndElement(); Serializer-StartElement(Lcontent, (_bstr_t)strNs, L, L); Serializer-WriteString(Ltest); // 短信内容 Serializer-EndElement(); Serializer-StartElement(Lreceivedate, (_bstr_t)strNs, L, L); Serializer-WriteString(L); // 空值也要有元素 Serializer-EndElement(); Serializer-EndElement(); // 收 send Serializer-EndBody(); Serializer-EndEnvelope(); // 第三段真正发出 hr SoapConnector-EndMessage(); if (FAILED(hr)) return _T(E_SEND); // 第四段读响应先看 Fault 再取结果 Reader.CreateInstance(__uuidof(SoapReader30)); Reader-Load(_variant_t((IUnknown*)SoapConnector-OutputStream), L); if (Reader-Fault ! NULL) return CString((LPCTSTR)Reader-Fault-faultstring); return CString((LPCTSTR)Reader-RpcResult-text); } catch (_com_error e) { return CString((LPCTSTR)e.Description()); } }参数的含义值得逐条对齐因为大部分调试时间都耗在下面这张表里代码位置取值含义写错的典型后果EndPointURL服务真正监听的地址可带端口连接失败或收到 404 页面SoapAction命名空间 方法名拼接服务端返回 Fault无法识别操作StartElement第二参数元素的 namespace URI参数全部被判为空业务校验失败StartEnvelope第三参数报文编码这里 UTF-8中文内容在服务端变问号Timeout秒级超时0 表示一直等服务端无响应时界面卡死2.1 建连EndPointURL 到底该不该带 ?wsdl原文把EndPointURL写成了.../SMS?wsdl。这是新手最容易照抄的错误之一因为浏览器打开这个地址确实能看到 WSDL 描述文件看起来服务就在那里。但?wsdl只是取描述文档的入口真正接收 SOAP 请求的是去掉查询串后的地址。带上它服务端可能返回一段 XML 描述而不是处理你的报文Connector 不一定报错Connect()甚至能返回S_OKEndMessage()之后拿到一个空结果。如果一个服务同时支持?wsdl和裸地址正确的判断方法是把?wsdl的内容存成.wsdl文件找里面soap:address location.../那行location的值就是EndPointURL一个字都别改。它经常和 WSDL 的访问地址不同尤其是走了反向代理之后。SoapAction的拼法有两个细节。第一命名空间结尾不该有斜杠或空格原文szNameSpace尾部多了一个空格拼出来是.../SMS/ send某些服务端做严格字符串比较时会直接判成未知操作。第二SOAP 1.1 的SoapAction值通常要用双引号包起来Toolkit 的Property赋值默认不加引号遇到挑剔的服务端可以手动写_bstr_t(\http://.../send\)。这两个点都不报编译错只会在运行时变成操作不存在。Timeout属性并不在原文里但它是生产环境的必需品。默认不设超时服务端挂住时 MFC 主线程会一起挂住用户看到的是整个界面假死。设置成 15 到 30 秒比较常见超时后EndMessage()返回失败至少能弹个提示框。2.2 拼报文StartElement 的四参数与命名空间对齐StartElement的签名是StartElement(Name, NamespaceURI, Prefix, EncodingStyle)。原文把命名空间写成了http://.../SMS?wsdl第二个坑就在这里——?wsdl属于 WSDL 文档的地址不属于元素命名空间。命名空间错了服务端反序列化时认不出这些元素表现是四个参数全为空值。prefix 参数原文传的是字符串NONE这个写法很奇怪。它会被当作 XML 前缀写进报文生成NONE:cmpcode这类标签虽然 XML 语法上合法但和 WSDL 里声明的元素形式对不上。常见的做法是传空串让 Toolkit 自己处理确实需要前缀时传Lns1这类合法前缀并在信封上声明它。元素的嵌套顺序也要和 WSDL 一致。RPC 风格的服务对参数顺序敏感cmpcode、phone、content、receivedate的顺序如果和xsd:sequence里声明的不同部分老服务端会按位置取值结果就是手机号里填进了短信内容。拿到 WSDL 之后把types段里的sequence抄下来代码里严格照排。空字符串参数不能省。原文的receivedate写的是空串元素本身还在这是对的。如果直接跳过不写这个元素服务端可能因为缺字段抛异常。对于没有值但必须存在的参数WriteString(L)比省略元素安全得多。2.3 收结果SoapReader 的 RpcResult 与 FaultSoapReader30从OutputStream装载响应。装载成功后有两个入口RpcResult是正常返回的聚合节点Fault是服务端主动抛出的错误。原文只取了RpcResult-text没有判断Fault所以服务端报错时这段代码会拿到空串或者访问空指针调试的人只能看到发送失败四个字。按 SOAP 1.1 的规范Fault下面有faultcode、faultstring、faultactor、detail四个子节点其中faultstring是给人看的说明faultcode是给程序判断的分类。把faultstring原样返回给上层日志里立刻就能看到企业代码不存在手机号格式错误这类具体原因比猜半天强得多。RpcResult-text的类型是BSTR也就是宽字符。原文写的是CString((const char*)Reader-RpcResult-text)把宽字符指针强转成窄字符指针再交给CString构造结果取决于工程是不是 Unicode 配置ANSI 工程下会得到乱码Unicode 工程下虽然碰巧能用但依然是错的写法。正确写法是CString((LPCTSTR)...)或者先接_bstr_t再转转换过程明确。3. 放进 MFC 工程COM 初始化、智能指针与三种失败分级代码贴进工程编译过了不等于能跑。MFC 工程里 COM 的初始化时机、智能指针的释放顺序、失败返回值的设计这三件事任何一件没处理好都会让排查方向彻底跑偏。3.1 AfxOleInit 与 CoInitializeEx 能不能混着用SOAP Toolkit 是 COM 组件调用前必须初始化套间。MFC 工程里最省事的做法是在CWinApp::InitInstance()开头调用AfxOleInit()它内部封装了OleInitialize同时把 OLE 相关的剪贴板、拖放一并初始化好。手动CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)也能用但不要在同一个线程里两种都调。AfxOleInit()内部调的是OleInitialize返回S_FALSE表示已经初始化过再叠一次CoInitializeEx且套间模型不同就会拿到RPC_E_CHANGED_MODE。这个错误码看起来和 WebService 毫无关系实际会让所有CreateInstance直接抛_com_error表现成函数一进去就返回错误描述。如果是工作线程里调用必须在线程函数开头自己CoInitialize(NULL)结束时CoUninitialize()。MFC 的AfxBeginThread起的线程不会自动初始化 COM这点在后台批量发送短信时特别容易踩。3.2 _com_ptr_t 与 _bstr_t 的释放顺序ISoapConnectorPtr这类_com_ptr_t智能指针在析构时自动Release()_bstr_t在析构时自动SysFreeString()看着省心但顺序反过来就有麻烦。假设把SoapConnector声明在函数开头Serializer声明在它之后。函数结束时析构顺序是反的Serializer先走SoapConnector后走这是对的。如果反过来把Serializer写在try块内部而SoapConnector在外部Serializer提前析构时它的InputStream引用还挂在 Connector 上问题不大但如果是把 Connector 声明在 Serializer 之后Connector 先析构Serializer 再访问它持有的流指针就会访问已释放内存。血泪经验是统一在函数开头一次性声明三个对象让析构顺序稳定Reader、Serializer、SoapConnector按这个顺序声明析构时正好是SoapConnector最后走。另外别把_bstr_t的临时对象传进Property之后还指望它长期有效Property赋值内部会复制但如果在同一语句里链式调用多个临时对象某些版本的 Toolkit 会留下悬空引用。3.3 HRESULT 与 Fault三种失败要返回三种信号原文在Connect()和EndMessage()失败时都返回一个空格 调用方完全分不清是网络断了、服务地址写错了还是参数被服务端拒了。这是排查成本最高的一种写法。合理的分级是三段Connect()失败返回E_CONNECT表示网络层或地址层问题检查端口、防火墙、服务是否启动EndMessage()失败返回E_SEND表示请求发出但传输中断检查超时设置、报文大小Reader-Fault ! NULL返回faultstring原文表示服务端收到了请求但业务校验没过检查参数内容。再加上catch (_com_error e)里返回e.Description()和e.Error()五种情况就全分开了。上层界面按前缀判断网络类问题提示检查网络业务类问题直接把服务端原文弹出来用户自己就知道是手机号填错了。这套分级花不了十行代码却能把平均排查时间从半天压到十分钟。4. 报文验证怎么确认发出去的就是你以为的那份 XMLSOAP 调用最难受的地方是黑匣子——中间隔着一层 COM看不见到底发了什么。断点只能停在EndMessage()前后而报文是 Toolkit 内部拼的。有几个办法能把它拽到明面上。4.1 发送前落盘把 InputStream 读出来看一眼SoapConnector-InputStream是一个实现了IStream的流对象。在EndMessage()之前可以把它完整读出来存成文件看到的就是即将发出的 XML 原文。// 在 EndMessage() 之前插入前提是流支持 Seek CComPtrIStream spStream SoapConnector-InputStream; LARGE_INTEGER liZero {0}; spStream-Seek(liZero, STREAM_SEEK_SET, NULL); std::string raw; char buf[1024]; ULONG nRead 0; while (spStream-Read(buf, sizeof(buf), nRead) S_OK nRead 0) raw.append(buf, nRead); // 落盘文件名带时间戳便于和抓包对照 CString strFile; strFile.Format(_T(soap_%s.xml), CTime::GetCurrentTime().Format(_T(%Y%m%d_%H%M%S))); CStdioFile f(strFile, CFile::modeCreate | CFile::modeWrite); f.WriteString(CString(raw.c_str())); f.Close(); spStream-Seek(liZero, STREAM_SEEK_SET, NULL); // 读完必须回绕否则发出去的是空报文这段代码的关键在最后一行。IStream是有位置的读完一遍指针停在末尾如果直接EndMessage()发出去的报文长度为零服务端返回 Fault 说空请求你会以为是参数问题实际是自己把流读空了。每次读完必须Seek回零点这是最容易忽略的一步。落盘之后用任何文本编辑器打开重点看三处SOAP-ENV:Envelope上的xmlns声明有没有?wsdlsend元素的命名空间属性和SoapAction是否一致中文参数在文件里是不是已经变成 UTF-8 字节。这三点一眼就能判断不用连服务端。4.2 用最小报文做对照实验有时候怀疑是参数值的问题其实问题在结构。拿一个只有一个参数的极简报文做对照比对着完整报文猜快得多先用记事本手写一份最小 XML用HttpConnector30的InputStream直接塞进去发服务端能正常返回说明地址和 SoapAction 是对的问题在报文体如果最小报文也失败问题就在连接层压根不用看参数。这个做法在原文这段代码里尤其好用因为它的参数有四个任何一个是空值都可能让服务端抛异常。把cmpcode、phone、content、receivedate逐个填真值试能快速定位是哪个字段的格式要求没满足。麻烦一点但比在四个参数里瞎试省时间。4.3 宽窄字符的三次转换UTF-8、BSTR 与 CStringStartEnvelope第三个参数写UTF-8指的是报文的编码声明。Toolkit 在把WriteString传进去的BSTR写进流时会按这个声明做一次转换。所以往WriteString里传宽字符中文是对的不要在那一层自己转成窄字符。麻烦出在读回结果的时候。RpcResult-text是BSTR服务端返回的中文已经是宽字符了直接CString((LPCTSTR)...)就能拿到可显示的文本。但如果是 ANSI 编译配置的 MFC 工程CString内部是char需要显式调CStringW再转或者用WideCharToMultiByte指定CP_UTF8。工程配置里使用 Unicode 字符集这一项对这类老代码的影响远比想象中大。还有一个隐蔽点服务端如果返回的是 UTF-8 编码的字节流而非 XML 声明里的编码SoapReader30可能会按声明去解解出来是乱码。这时候把响应原文也落盘一份用十六进制编辑器看中文字节是不是标准的 UTF-8 三字节序列就能确认是服务端编码不一致还是本地转换出了问题。5. 排查记录五种返回空串或乱码的现场这五种情况我都实际遇到过症状都是函数返回空串但成因完全不同。按现象 → 原因 → 解决记下来遇到时对照着查。5.1 现象Connect 返回 S_OK结果却是空串原因几乎都是EndPointURL带了?wsdl或者服务端返回的是一个 HTML 错误页而SoapReader30认不出RpcResult为空。也可能是SoapAction尾部多了空格服务端返回 Fault 但代码没判断Fault直接读了RpcResult。解决方式是先把落盘的请求报文和响应报文都打开看。响应如果是 HTML说明请求根本没进 SOAP 处理器检查地址和端口响应是正常的 SOAP Fault就按faultstring改参数。5.2 现象中文短信内容在手机上显示成问号原因在编码链路上断了某一环。StartEnvelope声明了 UTF-8但服务端可能按 GBK 解或者WriteString收到的本来就是窄字符转过来的乱码源头已经错了。还有一种可能content参数在服务端有长度限制超长部分被截断在半个汉字上。解决方式是先用纯英文内容测一遍英文正常说明链路通问题在编码再用两三个汉字测逐个排查是发送端还是接收端。确认服务端编码要求后如果它要 GBK就在发之前用WideCharToMultiByte转成 GBK 字节再塞进去同时把信封编码声明改成对应值。5.3 现象函数在 CreateInstance 处直接进 catch原因通常是 COM 没初始化或者mssoap30.dll没注册。前者见 3.1 节后者会在_com_error::Error()里返回REGDB_E_CLASSNOTREG。还有一种情况是在没有管理员权限的会话里跑组件注册在 HKLM 但当前用户读不到。解决方式是先确认AfxOleInit()的返回值失败会有明确提示再用注册表编辑器查HKCR\CLSID下有没有HttpConnector30对应的项。如果换机器部署记得把 Toolkit 的运行时一起装只拷 exe 是不行的。5.4 现象偶发超时重试一次就成功原因多半是服务端连接池满了或者网络抖动而代码里没设Timeout异常情况下线程会一直挂着用户以为程序死了。另一种可能是同一台机器上高频调用每次都新建 Connector端口被 TIME_WAIT 状态占满。解决方式是设 15 到 30 秒超时超时后返回明确的错误码让上层重试批量发送时复用同一个 Connector 对象或者至少控制调用频率两次之间留出间隔。别在循环里反复CreateInstance那个开销比想象中大。5.5 现象调试版正常Release 版返回乱码原因基本锁定在字符集上。调试版和 Release 版的工程配置不一定同步一个用 Unicode 一个用多字节CString的底层类型不同强转(const char*)的代码在其中一个配置下就会出问题。原文那行CString((const char*)Reader-RpcResult-text)就是典型的只在某一种配置下碰巧能用。解决方式是把所有BSTR转CString的地方统一改成CString((LPCTSTR)...)不要出现const char*强转。改完两个配置各编译一遍都跑通再提交。这类问题在本地永远测不出来一到客户现场就爆。6. 进阶把一次性代码封装成可复用的调用器原文这段代码是写死在对话框类里的参数靠函数入参传返回值只有一个字符串。实际项目里往往要调好几个方法这么写会复制粘贴出四五份几乎一样的函数。我一般的做法是抽一个CSoapClient小类把建连、发送、读取、日志四件事收进去业务层只负责填参数。class CSoapClient { public: CSoapClient(LPCTSTR lpszEndPoint, LPCTSTR lpszNameSpace); ~CSoapClient(); // 返回 true 表示调用成功strResult 为结果文本strError 为失败原因 bool Invoke(LPCTSTR lpszMethod, const std::vectorstd::pairCString, CString params, CString strResult, CString strError); private: ISoapConnectorPtr m_spConnector; // 复用连接器避免每次重建 CString m_strNs; bool m_bConnected; };三个值得加的细节。第一是连接复用构造函数里建连一次Invoke只做BeginMessage到EndMessage这一段高频调用时省掉大量握手开销。第二是统一日志在Invoke内部把请求和响应各落一份文件出问题时不用改代码重新编译直接翻日志。第三是重试策略对E_SEND这类传输失败做一次重试对参数类 Fault 不重试避免把一条短信发两遍。从 WSDL 反推参数这件事也有章法。把?wsdl的内容存下来按顺序找三样东西WSDL 中的位置对应代码里的什么definitions targetNamespace...SoapAction的前半段、StartElement的命名空间soap:operation soapAction...SoapAction的完整值可直接照抄xsd:element name... type...与sequence元素名、参数顺序、必填还是可空soap:address location...EndPointURL原样复制对着这张表基本不用猜。真正容易翻车的是那些走反向代理的服务location里写的是内网地址外网访问要换成域名这时候以实际能连通的地址为准同时确认服务端没有按 Host 头做校验。最后说个验证方法。改完封装之后别急着接到业务里跑先用一个最小测试函数把所有方法各调一遍把响应落盘人工核对一遍返回值。等业务层接上去之后再出问题你面对的就是两套变量——封装本身的 bug 和业务参数的 bug定位难度翻倍。先把封装单独验干净后面每加一个方法都是十分钟的事。从那以后我每次接这类老接口都强制先做两件事把?wsdl存成文件从里面抄location和soapAction在EndMessage()前把报文落盘看一眼 XML。这两步各花不到五分钟但能挡掉后面八成的时间黑洞。这套代码后来被我整理成一个模板新项目里遇到 SOAP 调用直接改地址和字段名就能用。希望帮到你。本文还有配套的精品资源点击获取