
简介面向使用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_namespaceMFC 程序一般在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 targetNamespacehttp://soap.example.test/cif/services/SMS binding nameSMSBinding typetns:SMSPortType soap:binding transporthttp://schemas.xmlsoap.org/soap/http/ operation namesend soap:operation soapActionhttp://soap.example.test/cif/services/SMS/send/ /operation /binding service nameSMSService port nameSMSPort bindingtns:SMSBinding soap:address locationhttp://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-ENVhttp://schemas.xmlsoap.org/soap/envelope/ SOAP-ENV:Body send xmlnshttp://soap.example.test/cif/services/SMS cmpcode0001/cmpcode phone00000000000/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去掉只保留服务 endpointWSDL 地址单独放在配置或文档里用来核对 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 一致且不带?wsdlAction从 WSDL 复制soapAction和代码里的action完全一致报文记录序列化后的 XML父元素、子元素命名空间和 WSDL 对齐返回保存原始响应RpcResult能取到值或能按结构体解析还有两个细节容易被忽略。第一短信发送不是天然幂等的超时后不要盲目重试否则可能重复发短信要重试就带业务流水号让服务端去重。第二Timeout不要设得太短老网关首次连接可能比较慢先给 5 秒再根据日志调整。从那以后我每次接老 SOAP 接口都先把 WSDL 里的 endpoint、action、namespace 抄到一张表里再用最小报文打通最后才接业务参数。这份示例代码和报文模板可以按资源入口获取拿去对着自己的 WSDL 改三处就能跑。希望帮到你。本文还有配套的精品资源点击获取