ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC6老项目调WebService:用SOAP Toolkit免框架直连SOAP接口

VC6老项目调WebService:用SOAP Toolkit免框架直连SOAP接口 简介VC调用Web Service是本地应用程序与远程服务器功能交互的常见需求。这份doc文档以完整示例代码演示了如何通过COM接口使用SOAP协议调用Web Service面向需要在VC工程中集成短信发送等远程服务的开发者。文档核心基于SOAP Connector与SOAP Serializer围绕HttpConnector30、SoapSerializer30等组件展开将设置EndPointURL、建立连接、定义SoapAction、构造SOAP消息体、传递cmpcode/phone/content等参数以及发送与读取响应的全过程逐行拆解并给出可直接参考的BeginSoap函数封装。资源包仅1个doc文件大小28KB轻量精炼适合作为VC调用Web Service的入门与排错参照目前已有179人学习下载。通过该文档读者可理解SOAP消息结构与XML序列化流程快速掌握基于COM组件调用Web Service的套路与关键接口用法减少重复调试成本。1. VC调用WEBSERVICE老MFC项目直连SOAP接口不引框架也能跑通如果你维护的项目还躺在VC6.0里突然接到一个发条短信的需求而对方只丢给你一个SOAP接口地址你多半会先想到找CURL、找第三方SOAP库。但实测下来VC调用WEBSERVICE最稳的路线是直接用系统组件里的SOAP Toolkit一个HttpConnector30实例加上Serializer、Reader就能完成从连接、组包到解析响应的完整闭环不需要在工程里引入任何额外依赖。这篇文章适合被遗留代码拴住、又不想为一个小接口重上框架的开发者。我会把连接器选型、报文组包、响应解析和常见翻车点拆开讲每一步都给出可以直接抄的参数取值与排查方法。2. 连接器选型为什么系统COM组件比第三方SOAP库更省心2.1 老VC工程调WebService的三条路线在VC6年代一个MFC程序要调WebService实际能走的路并不多。我见过的大致有三条第一条是MSXML加XMLHTTP自己拼SOAP XML再POST出去自由度最高但命名空间、连接复用、超时控制全得自己管调试时在字符串和DOM之间来回切换效率很低第二条是引入gSOAP或libcurl加libxml2这类第三方库功能完整但VC6对现代C标准支持有限编译依赖、运行时分发、许可证合规都要额外花时间第三条是直接用Microsoft SOAP Toolkit 3.0提供的COM组件用HttpConnector30负责HTTP传输SoapSerializer30负责组包SoapReader30负责解析响应三件套正好对应SOAP调用的三个环节。如果你熟悉的是Java方向调WebService的套路比如用CXF或Axis2生成客户端桩那在VC6里会明显感到没轮子可用——没有注解、没有代理类生成器只能跟COM接口和XML结构死磕。但也正因为VC这边的轮子少SOAP Toolkit的三件套反而成了最不需要动脑的选择。下面这个表格可以帮你在做技术方案时快速向同事解释为什么选它方案依赖复杂度适合场景主要负担MSXMLXMLHTTP系统自带简单POST、调试报文手拼XML、无状态管理gSOAP/libcurl需要编译与分发复杂服务、长期维护库升级、许可证、VC6兼容SOAP Toolkit COM注册mssoap30.dllWSDL明确、快速接入组件老旧、只支持SOAP/HTTP我的选型结论是当接口只有一个、方法不多、且明确走SOAP over HTTP时SOAP Toolkit COM组件是性价比最高的。它不需要改工程属性、不需要改编译选项只要在stdafx.h里加一条#import指令就能在MFC代码里直接使用。对老项目来说这种低侵入性是第三方源码库给不了的。2.2 初始化连接器CoInitialize、CreateInstance与Property赋值选型定了接下来是把连接器跑起来。第一步是初始化COM环境MFC工程的InitInstance里一般已经做过但如果你在Worker线程里调WebService线程内必须重新CoInitialize否则CreateInstance会返回错误。下面是一个最小可用的初始化片段#include windows.h #import mssoap30.dll named_guids no_namespace using namespace MSSoap30; ::CoInitialize(NULL); ISoapConnectorPtr SoapConnector; HRESULT hr SoapConnector.CreateInstance(__uuidof(HttpConnector30)); if (FAILED(hr)) { // 处理组件未注册的情况见避坑章节 }代码里named_guids和no_namespace两个属性很关键。named_guids让编译器生成__uuidof可用的GUID常量no_namespace去掉类型库自带的命名空间前缀避免与ATL、MFC的命名空间互相污染。如果你的工程里同时引用了其他mssoap相关头文件建议保留命名空间前缀避免重名冲突。组件创建成功后紧接着要设置两个属性EndPointURL和SoapAction。EndPointURL决定请求发往哪个地址SoapAction决定服务端把这个请求路由给哪个方法。这里有一个很容易被忽略的点Connect()只建立HTTP连接并不校验SoapAction对应的操作方法是否存在。所以即使地址里带了个错误的路径Connect也可能返回成功真正的错误要等报文发完、读到响应时才暴露。这也解释了为什么很多人在Connect()处没报错却在EndMessage()之后收到HTTP 500。SoapConnector-Property[EndPointURL] http://your-server:8080/cif/services/SMS; hr SoapConnector-Connect(); if (FAILED(hr)) return; SoapConnector-Property[SoapAction] http://your-server:8080/cif/services/SMS/send;注意这里的地址和SoapAction里的命名空间要和服务端WSDL完全一致。很多SOAP服务要求EndPointURL以?wsdl结尾才能正确识别也有服务端只接受裸地址。如果拿不准我一般用浏览器直接访问WSDL页面看页面顶部wsdl:definitions targetNamespace和soap:address location标签里的值这两个值就是SoapAction命名空间和EndPointURL的权威来源。提示代码里所有your-server都是演示占位地址联调时务必替换成你从WSDL里解析出的实际host和端口。2.3 连接对象与WSDL的真实对应关系再深一层szNameSpace在代码里的作用不只是SoapAction的前缀。Serializer组包时StartElement里的命名空间URI必须和它保持一致否则服务端解析SOAP Body时会认为元素不属于目标命名空间返回找不到方法或参数不匹配。WSDL里有两处地方最值得先看一是soap:address location它决定了EndPointURL应该填什么二是每个operation下的soap:operation soapAction它决定了SoapAction应该填什么。把这两个值从WSDL复制到代码里比手动拼字符串可靠得多。我习惯在工程里定义两个宏一处修改、处处生效#define SMS_NAMESPACE _T(http://your-server/cif/services/SMS) #define SMS_SOAPACTION SMS_NAMESPACE _T(/send)后面的Serializer组包和SoapAction赋值都引用这两个宏从根源上杜绝两个位置各写一遍带来的拼写不一致。另外还要注意Connect()默认使用的超时时间来自注册表里SOAP Toolkit的默认设置服务端响应慢的时候常常在EndMessage()处卡住很久才报错。如果希望快速失败可以在连接后尝试设置Property[ConnectTimeout]和Property[Timeout]单位是毫秒。这两个属性在SOAP Toolkit 3.0的文档里有说明但很多参考代码不会主动设置我建议在网络环境不稳定的场景下显式配置。超时值设得太短会导致正常业务在服务端慢时误杀一般ConnectTimeout给10秒、Timeout给30秒起步具体看接口的SLA。3. 组包原理与参数落位Serializer序列化SOAP报文的完整流程3.1 Envelope、Body、Element的嵌套顺序不能乱SOAP消息本质上是一棵XML树。最外层是EnvelopeEnvelope下面挂BodyBody里面放具体业务元素。Serializer的方法序列就是在手工画这棵树——StartXxx开一层EndXxx收一层次序反了生成的XML就结构错乱。下面是短信接口的核心组包代码我把参数名和原文的接口字段保持一致方便你对照WSDLISoapSerializerPtr Serializer; Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer-Init(_variant_t((IUnknown*)SoapConnector-InputStream)); Serializer-StartEnvelope(LSOAP-ENV, L, LUTF-8); Serializer-StartBody(); Serializer-StartElement(send, SMS_NAMESPACE, , ); Serializer-StartElement(cmpcode, SMS_NAMESPACE, NONE, ); Serializer-WriteString(0166); Serializer-EndElement(); Serializer-StartElement(phone, SMS_NAMESPACE, NONE, ); Serializer-WriteString(13258310354); Serializer-EndElement(); Serializer-StartElement(content, SMS_NAMESPACE, NONE, ); Serializer-WriteString(utf8Content); Serializer-EndElement(); Serializer-StartElement(receivedate, SMS_NAMESPACE, NONE, ); Serializer-WriteString( ); Serializer-EndElement(); Serializer-EndElement(); // end send Serializer-EndBody(); Serializer-EndEnvelope();StartEnvelope的三个参数依次是前缀、命名空间、字符集。前缀写SOAP-ENV是SOAP 1.1的惯例服务端不会死板校验字符集必须写UTF-8这一点在中文字符场景下直接决定服务端能不能正确解码。StartBody的参数是Header元素名通常传空字符串即可不需要显式组装Header。StartElement的四个参数分别对应元素名、命名空间、schema类型占位、附加属性。第三个参数NONE表示不声明类型多数业务接口都能接受如果服务端WSDL里的元素带type属性比如要求发送时间字段是xsd:dateTime则需要把schema类型换成具体URI。第四个参数一般留空只有需要追加xmlns或xsi:nil属性时才用。组包结束后内存里形成的报文大致长这样这是抓包能看到的结构?xml version1.0 encodingUTF-8? SOAP-ENV:Envelope xmlns:SOAP-ENVhttp://schemas.xmlsoap.org/soap/envelope/ SOAP-ENV:Body send xmlnshttp://your-server/cif/services/SMS cmpcode0166/cmpcode phone13258310354/phone content你好/content receivedate /receivedate /send /SOAP-ENV:Body /SOAP-ENV:Envelope拿到这个XML再对照WSDL一眼就能看出元素层级和命名空间对不对。我建议每次联调前先把报文打印出来核对一遍比盯着代码反复看高效得多。3.2 WriteString与中文编码先把宽字节转成UTF-8再入流WriteString接收char*字符串会将文本转义为XML安全字符。它的好处是不需要手写实体转换短信内容里出现、、时会自动变成amp;等实体服务端解析回来就是原文。但它不做编码转换——你传进去的字节是什么报文里就是什么。在多字节字符集工程里CString里的中文字符串是GB2312字节序直接WriteString到声明为UTF-8的Envelope里服务端按UTF-8解码就会得到乱码。我的处理方式是在组包前统一把内容转成UTF-8字节流下面是函数签名char* UnicodeToUtf8(const CString strSrc) { int nWideLen MultiByteToWideChar(CP_ACP, 0, strSrc, -1, NULL, 0); wchar_t* pWide new wchar_t[nWideLen]; MultiByteToWideChar(CP_ACP, 0, strSrc, -1, pWide, nWideLen); int nUtf8Len WideCharToMultiByte(CP_UTF8, 0, pWide, -1, NULL, 0, NULL, NULL); char* pUtf8 new char[nUtf8Len]; WideCharToMultiByte(CP_UTF8, 0, pWide, -1, pUtf8, nUtf8Len, NULL, NULL); delete[] pWide; return pUtf8; }调用时先UnicodeToUtf8拿结果再WriteString用完记得释放返回的堆内存。这个函数只负责把本地代码页转成UTF-8前提是工程字符集是MBCS如果是Unicode工程CString底层就是宽字符直接调WideCharToMultiByte(CP_UTF8...)即可省掉第一轮MultiByteToWideChar。有一个细节如果短信内容本身允许用户换行转换后字符串里会带\nWriteString不会把换行转成#10;之类的实体。多数服务端能接收原始换行符但有严格格式校验的服务可能报错。遇到这种情况需要在转换后把\n替换成br/或者在业务侧约定传纯文本无换行。3.3 空值与复杂类型receivedate、可空字段与数组的传法原文里receivedate字段通过WriteString( )传了一个空格这在联调中属于玄学做法。空格字符在XML里是合法文本服务端用trim()处理就能得到空串但严格的Schema校验会认为字符串 不符合空枚举或dateTime类型。更稳妥的做法是跳过StartElement直接不传前提是WSDL里该元素的minOccurs0如果服务端强制要求该字段存在则需要向接口提供方确认空值的语义。遇到复杂类型参数时Serializer同样用嵌套元素表达。比如一个批量发送接口方法参数是数组组包方式是在send元素下重复同名子元素Serializer-StartElement(send, SMS_NAMESPACE, , ); for (int i 0; i phoneArray.GetSize(); i) { Serializer-StartElement(phone, SMS_NAMESPACE, NONE, ); Serializer-WriteString((LPCSTR)phoneArray[i]); Serializer-EndElement(); } // 其他公共字段 Serializer-EndElement();重复多个同名元素是SOAP表达数组的常见方式服务端拿到后会按顺序组装成数组。需要注意有的服务端要求数组元素带SOAP-ENC:arrayType属性这时第四个参数就不能留空要写成SOAP-ENC:arrayType\xsd:string[ count ]\的完整属性串。这个格式很容易出错务必以WSDL里的complexType声明为准。4. 收发闭环从EndMessage到RpcResult的响应处理4.1 BeginMessage到EndMessage的调用顺序与状态管理组包完成后消息停留在连接器的InputStream里还没有进入网络。真正的提交动作是EndMessage()它把序列化好的XML发送给服务端并把响应写入OutputStream。整个调用顺序是一个严格的状态机Connect→BeginMessage→Serializer.Init(InputStream)→组包→EndMessage→Reader.Load(OutputStream)。这个顺序里有一个容易被忽视的坑如果同一连接器对象要发送第二次消息第一次EndMessage之后不能马上BeginMessage。SOAP Toolkit 3.0的连接器内部状态在消息结束后并没有完全复位InputStream里可能残留上一次的XML尾部导致第二次组包时出现Unknown token之类的解析错误。我的做法是封装一个发送函数每次都新建连接器实例CString CWebservice_vcDlg::BeginSoap( CString UserName, CString Password, CString WebUrl, CString phone, CString content) { HRESULT hr; CString strResult; try { ISoapConnectorPtr SoapConnector; SoapConnector.CreateInstance(__uuidof(HttpConnector30)); SoapConnector-Property[EndPointURL] WebUrl; hr SoapConnector-Connect(); if (FAILED(hr)) return _T(); SoapConnector-Property[SoapAction] SMS_NAMESPACE _bstr_t(/send); SoapConnector-BeginMessage(); ISoapSerializerPtr Serializer; Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer-Init( _variant_t((IUnknown*)SoapConnector-InputStream)); // 组包逻辑见第3章 Serializer-EndEnvelope(); hr SoapConnector-EndMessage(); if (FAILED(hr)) return _T(); ISoapReaderPtr Reader; Reader.CreateInstance(__uuidof(SoapReader30)); Reader-Load( _variant_t((IUnknown*)SoapConnector-OutputStream), _T()); if (Reader-RpcResult ! NULL) { strResult CString((const char*)Reader-RpcResult-text); } } catch (_com_error e) { strResult CString((char*)e.Description()); } return strResult; }封装的关键点是每个线程调用时SoapConnector、Serializer、Reader三个对象局部创建作用域结束后自动Release不依赖外部状态。参数里带用户名密码但在这个短信接口里其实没参与组包——很多老接口的用户名密码只是服务端调用方白名单不塞进SOAP Body这一点需要在联调时向接口方确认别想当然地加元素。4.2 RpcResult解析单值返回与复杂结构的边界响应读取使用SoapReaderReader-Load之后的RpcResult是SOAP Body下第一个业务元素的引用。RpcResult-text返回该元素包含的文本适合短信接口这类返回值就是一个状态串的场景。原文代码直接把这个字符串丢给上层调用方简单直接但需要注意三个边界。第一个边界是空响应。服务端可能在业务异常时返回一个不带业务元素、只有fault节点的SOAP报文RpcResult此时为NULL直接访问RpcResult-text会触发访问违例。所以读取时要先判空再取text。第二个边界是复杂结构。如果响应是sendResponsestatus1/statusmsgok/msg/sendResponse只取text会拿到整段字符串还得自己按标签拆。这种场景要用XML DOM接口遍历子节点而不是依赖RpcResult-text。第三个边界是XML实体。服务端返回的文本里如果包含amp;text属性拿到的原始文本就带着实体直接展示会露馅。需要额外做一次二级解析或替换。if (Reader-RpcResult ! NULL) { _bstr_t bstrText Reader-RpcResult-text; strResult (char*)bstrText; }RpcResult的类型是IXMLDOMElementPtrtext是DOM Level 3里的标准属性它返回该节点后代所有文本的拼接。如果服务端返回returntrue/return这里拿到的就是true如果返回returncode1/codemsgok/msg/returntext会把两个叶子文本拼成1ok没有任何分隔——这基本可断定接口返回的是复杂结构需要换解析策略。4.3 失败归因当HRESULT不是判断错误的唯一标准原文代码在关键步骤都判断了FAILED(hr)但实际联调中很多业务层面的错误不会体现在HRESULT里。EndMessage()返回S_OK不意味着服务端处理成功HTTP 500响应体里的SOAP Fault会被Toolkit解析出来放在Reader-fault节点但HRESULT可能仍是S_OK。所以我把失败归因分成两层传输层错误看HRESULT或异常业务层错误看响应体里的fault节点和业务字段。catch (_com_error e) { return CString((char*)e.Description()); }_com_error的Description()在组件层能给出可读信息但这个描述往往只覆盖传输层问题。真正业务侧的报错信息比如参数xxx不能为空手机号格式不对需要到SOAP Fault的faultstring子节点里取。建议在读取响应时先检查Reader-fault是否存在存在则优先返回fault里的描述否则再取RpcResult。超时处理上EndMessage()是同步阻塞调用服务端响应慢时整个界面会卡住。在MFC对话框程序里如果不想让UI假死这里需要放到工作线程里跑。我一般用AfxBeginThread拉起一个发消息线程主线程用PostMessage接收结果避免在OnBnClicked里直接调BeginSoap。这是MFC老项目里最常见也最实用的异步改造方式。线程回调里记得重新CoInitialize因为MFC的InitInstance里的COM初始化不会自动传播到子线程。5. 避坑指南VC6调SOAP接口最容易翻车的五个位置5.1 COM组件创建失败提示Class not registered现象SoapConnector.CreateInstance(__uuidof(HttpConnector30))返回REGDB_E_CLASSNOTREG或者运行时弹未注册的组件对话框。原因目标机器没有安装SOAP Toolkit 3.0mssoap30.dll未被注册为COM组件。这个问题在开发机一般不会出现因为装过VC6或相关SDK的机器可能已带组件在用户机器和干净的测试机上大概率复现。解决在安装包里带上mssoap30.dll并执行regsvr32 mssoap30.dll。注意64位系统下DLL要复制到C:\Windows\SysWOW64并使用64位版本的regsvr32执行注册如果程序编译的是32位组件注册路径错误会直接导致找不到类的错误。注册完成后可以用regsvr32 /u先反注册再重新注册排除版本冲突。5.2 Connect成功但EndMessage之后收到HTTP 500现象Connect()返回S_OKSerializer组包也正常EndMessage()执行后响应解析出来是HTTP 500 Internal Server Error或者直接抛出异常。原因最常见的两个原因一是SoapAction的值与服务端WSDL里soap:operation soapAction不一致服务端无法路由操作二是Serializer组包时StartElement的命名空间与SoapAction的命名空间不是同一个值服务端解析Body时判定元素不属于当前操作。还有一个细节EndPointURL写错路径但Connect不校验也会在服务端返回404表现同样是EndMessage后报错。解决用浏览器打开WSDL地址把soap:address location、soap:operation soapAction、targetNamespace三个值抄出来和代码逐一比对。我建议把命名空间定义成宏统一引用不要在两处各写一遍字符串。若确认代码无误还报500让接口方开SOAP日志看服务端收到的实际报文。5.3 短信内容中文乱码服务端收到的是一堆问号现象短信发到用户手机上是或鎴戜綘而不是正常中文。原因工程字符集是MBCSWriteString按GB2312字节序写入Envelope却声明了UTF-8。服务端按UTF-8解码这些GB2312字节自然得到乱码。这个和HTTP头里的Content-Type关系不大问题出在字节流本身。解决组包前用MultiByteToWideChar加WideCharToMultiByte(CP_UTF8)把内容转成UTF-8字节流再传给WriteString。转换函数要处理动态分配的内存记得释放。如果服务端支持GB2312或GBK编码也可以在StartEnvelope里声明对应字符集但那样通用性差统一UTF-8更稳。5.4 响应里的RpcResult-text和预期不一致夹带XML标签或实体现象返回的字符串显示为success1code101或者响应里多个字段被拼接成一串。原因text属性返回的是DOM节点下所有文本节点的拼接遇到复杂返回结构时会把多个子元素的文本拼在一起。实体amp;是服务端原始返回内容的一部分Toolkit不会二次解码。解决先看WSDL里sendResponse的类型定义。如果返回的是简单xsd:string直接用text没问题如果返回结构体或数组改用RpcResult-childNodes遍历节点。对实体字符按业务约定做替换比如把amp;转回再解析。不要指望SOAP Toolkit做额外处理它在响应这块只提供DOM访问不负责业务语义。5.5 连续发送第二次失败报Unknown token现象同一个连接器对象连续调用BeginMessage第一次能发出去第二次在Serializer组包时报Unknown token或Invalid SOAP message。原因连接器对象在EndMessage之后状态未完全复位InputStream里残留上一次的XML尾部再次BeginMessage时新内容与残留混在一起。解决不要复用连接器实例每次发送都重新CreateInstance。如果需要频繁发送可以考虑循环内创建新对象性能损耗相比连接超时带来的卡顿几乎可以忽略。也可以在EndMessage之后手动调用Release并置空指针但救不了复用带来的状态残留最稳的还是新建。6. 进阶验证搭一个本地桩服务把报文看清再上真实接口6.1 桩站搭建与切换步骤真实服务不是随时都能联调的尤其是短信网关这类外部接口测试环境经常要排队。没环境的时候我会先起一个本地桩服务把VC组包发来的HTTP请求完整打印出来再返回一个固定XML。这样能把客户端组包问题和服务端业务逻辑问题彻底分开。用Python的http.server就能做不需要装框架from http.server import BaseHTTPRequestHandler, HTTPServer class SoapStub(BaseHTTPRequestHandler): def do_POST(self): length int(self.headers.get(Content-Length, 0)) body self.rfile.read(length) print(SOAPAction:, self.headers.get(SOAPAction)) print(Body:, body.decode(utf-8, errorsignore)) resp ?xml version1.0 encodingUTF-8? soap:Envelope xmlns:soaphttp://schemas.xmlsoap.org/soap/envelope/ soap:Body sendResponse xmlnshttp://your-server/cif/services/SMS returnsuccess/return /sendResponse /soap:Body /soap:Envelope self.send_response(200) self.send_header(Content-Type, text/xml; charsetutf-8) self.send_header(Content-Length, str(len(resp.encode(utf-8)))) self.end_headers() self.wfile.write(resp.encode(utf-8)) HTTPServer((127.0.0.1, 8080), SoapStub).serve_forever()桩站启动后把VC代码里的EndPointURL指向http://127.0.0.1:8080/SoapAction保持原值。桩站会打印出实际的SOAPAction头和完整报文组包里命名空间、元素名、参数值有没有错位一眼就能看到。桩站通了说明组件调用、序列化顺序、编码处理全部正确桩站不通直接在打印出来的XML上找差异比拿着_com_error的信息去猜服务端为什么拒绝要快得多。确认桩站验证通过后把EndPointURL切回真实服务地址再做一次真实联调。此时如果还有问题基本可以断定是服务端配置或业务参数语义的问题客户端这边不用再反复折腾了。从那以后我每次接新SOAP接口都会先花半小时搭一个本地桩把报文打出来核对一遍再连真实服务。这个习惯让后来几次联调从靠猜变成了靠看尤其对VC6这种调试手段有限的环境多一道本地验证就是少一次远程翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表