简介:面向使用 VC++ 进行桌面开发并需要与远程服务交互的开发者,这份资料围绕通过 COM 接口调用 Web Service 展开,重点演示 SOAP 连接器、终结点地址设置、SoapAction 指定、消息序列化以及发送与响应读取等关键环节。包内共 1 个 doc 文件,压缩包约 28KB,篇幅精简,便于快速查阅和对照代码理解调用流程。已有 180 人学习下载,适合作为接口联调前的入门参考。读者可从中了解 HttpConnector30、SoapSerializer30、SoapReader30 等组件的配合方式,看到短信发送示例中参数如何写入 SOAP 消息体,并据此梳理 VC++ 集成 Web Service 的基本思路与常见异常处理入口,减少自行摸索成本。
1. 从一段BeginSoap说起:VC++ 老工程为什么还要用 COM 调 SOAP
维护 VC++ 老工程的人看到VC调用WEBSERVICE,通常不是想补 SOAP 理论,而是手里有个 MFC 对话框要调远程短信接口,programming 层面的问题很具体:连接器怎么建、命名空间填什么、返回乱码怎么排。示例以BeginSoap为核心,用HttpConnector30、SoapSerializer30、SoapReader30这组 COM 接口完成建连、序列化、发送和读响应。它适合仍在内网维护 MFC/C++ 桌面程序、又不想为一次 SOAP 调用引入整套新框架的人;不适合新项目一上来就选 SOAP 的场景。下面按可复现顺序拆开。
2. 把调用链拆开:HttpConnector30、SoapSerializer30 与工程依赖怎么配
这段代码看起来只是一个函数,实际由四个角色拼起来:连接器负责 HTTP 传输,序列化器负责把参数写成 SOAP XML,读取器负责解析响应,CString负责把结果带回 MFC 界面。很多失败不是业务逻辑写错,而是工程依赖、服务契约和对象生命周期没对齐。先把职责边界认清,再动手配工程,后面排错才不会靠猜。
2.1 先认清四个 COM 对象的职责边界
HttpConnector30不是“WebService 客户端”本身,它更像一个带 SOAP 语义的 HTTP 通道。EndPointURL决定请求发到哪里,SoapAction决定服务端把这次请求分派给哪个方法,Connect()只是把通道准备好,真正写 XML 的是SoapSerializer30,真正读响应的是SoapReader30。示例里SoapConnector->InputStream和OutputStream分别接到序列化器和读取器,这条流的方向一旦接反,表现就是“请求发出去了但参数为空”或“响应读不到”。
常见选型有三类:用这套 COM 接口、用第三方 SOAP 生成器、用 WinHTTP 手工拼 XML。老 MFC 工程已经有 COM 初始化、对话框和CString基础设施,用 COM 接口的迁移成本最低;代价是组件注册、位数匹配和类型库导入都要自己管。手工拼 XML 看似自由,实际要把命名空间、转义、编码、HTTP 头全部自己兜住,维护量并不小。第三方生成器适合新工程,但对一个只调一两个方法的老对话框来说,引入构建链反而重。我的判断标准很简单:如果服务契约稳定、调用点少、机器环境可控,就用 COM 骨架;如果服务经常变、要跨平台,就别在这条路上硬扛。
还要注意BeginSoap的函数签名里有UserName、Password,但示例请求体里并没有构造鉴权头。这两个参数更可能是预留位,不代表认证已经完成。服务端如果要求用户名密码,通常要加 SOAP Header,或者由传输层、网关侧处理;不要以为把参数传进函数就自动带上了。这个误解在联调时很常见,表现是“本地参数明明传了,服务端一直说未授权”。
2.2 工程侧的三件事:初始化、导入类型库、注册组件
第一件事是 COM 初始化。MFC 工程里我一般用AfxOleInit(),它在应用启动时完成 OLE/COM 初始化,只调用一次;如果是在非 MFC 的控制台或服务里,就用CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED),并在退出时配对CoUninitialize()。初始化放在创建HttpConnector30之前,不要等按钮点击了才想起来。
BOOL CSoapDemoApp::InitInstance() { // MFC 工程只初始化一次 OLE/COM,后续对话框才能创建 COM 对象 if (!AfxOleInit()) { AfxMessageBox(_T("OLE 初始化失败")); return FALSE; } CWinApp::InitInstance(); return TRUE; }这段代码的逻辑是把 COM 初始化提前到应用启动阶段,参数为空表示使用当前线程模型;对 MFC 对话框程序来说,AfxOleInit()比在OnInitDialog里随手CoInitialize更稳,因为不会出现重复初始化或退出未释放。参数说明上,AfxOleInit()没有业务参数,返回值只表示初始化是否成功;如果失败,先看工程是否链接了 MFC 的 OLE 支持,而不是继续查 WebService 地址。
第二件事是导入类型库。代码里的ISoapConnectorPtr、ISoapSerializerPtr、ISoapReaderPtr以及__uuidof(HttpConnector30)都依赖类型库生成的声明。常见做法是在使用这些接口的 cpp 文件顶部写#import,让编译器生成智能指针和接口声明。
#include <comdef.h> // 导入 SOAP COM 组件的类型库,生成 ISoapConnectorPtr 等智能指针 #import "mssoap30.dll" no_namespace named_guids这段代码的逻辑是把组件类型库引入工程,no_namespace让接口名直接可用,named_guids生成带名字的 GUID 常量,方便__uuidof和调试。参数说明上,mssoap30.dll是组件文件名,实际机器上要先有这套组件;如果编译报“无法打开类型库”,不是代码问题,而是组件没装或路径不在编译器搜索范围。不要手写一堆IUnknown强转来绕开类型库,后面属性访问和_com_ptr_t生命周期会更难维护。
第三件事是注册组件。命令本身不复杂,坑在位数和权限:
# 以管理员权限注册组件;32 位程序与 64 位程序要对应各自位数的组件 regsvr32 /s mssoap30.dll # 注册后可用下面的命令确认注册表里能查到类信息(按实际组件名调整) reg query "HKCR\CLSID" /f "HttpConnector30" /s这段命令的逻辑是先注册再验证,/s表示静默注册,不弹成功框;reg query只是辅助确认类是否注册成功。参数说明上,mssoap30.dll要换成你机器上的实际路径,/s不代表“注册一定成功”,失败时去掉/s看错误码更直接。32 位 VC++ 程序调 32 位组件,64 位程序调 64 位组件,混了以后CreateInstance经常返回“类未注册”,但组件明明“注册过了”。
注意:不要在一台机器上同时装多个来源不明的同名组件,注册表里留下旧 CLSID 时,排查会变成黑匣子。先确认组件来源和位数,再谈调用。
2.3 服务契约先核对:EndPointURL、SoapAction 与命名空间
代码里最容易照抄错的是三个字符串:EndPointURL、SoapAction、命名空间。EndPointURL是请求真正发往的地址,通常不带?wsdl;?wsdl地址是给你看契约的,不是给HttpConnector30发 SOAP 请求的。示例里把?wsdl写进了终结点和元素命名空间,这种写法在宽松服务端可能能过,在严格服务端会直接 500。联调时先把这三个值抄到同一张表里,别在代码里到处散落。
| 配置项 | 示例占位值 | 取值来源 | 常见错误 |
|---|---|---|---|
EndPointURL | http://soap.example.test/cif/services/SMS | WSDL 里的soap:address location | 把浏览器打开的?wsdl地址直接填进去 |
SoapAction | http://soap.example.test/cif/services/SMS/send | WSDL 里对应操作的soapAction | 少斜杠、少命名空间、大小写不一致 |
| 元素命名空间 | http://soap.example.test/cif/services/SMS | WSDL 的targetNamespace | 把 WSDL 地址当命名空间,或末尾斜杠多一个 |
| 方法名 | send | WSDL 里的操作名 | 大小写和拼写不一致 |
| 参数名 | cmpcode、phone、content、receivedate | WSDL 里的元素定义 | 自己改短名、加前缀、顺序错乱 |
这张表不是让你背,而是让你在联调前先做一次对齐。SoapAction是 HTTP 头,不是 XML 里的元素;它和命名空间的拼接规则在不同服务端可能不同,有的要求namespace/send,有的要求namespace#send,最终以 WSDL 的soapAction为准。命名空间末尾的斜杠也是敏感字符,.../SMS和.../SMS/在两个服务端可能一个成功一个失败。我的习惯是把EndPointURL、targetNamespace、SoapAction做成三个常量或配置项,改的时候只改一处,日志里一次打全。
3. 按BeginSoap复现:从建连到读响应的可运行骨架
这一章把函数拆成四段:建连、序列化、发送读响应、参数外置。每段都能单独编译验证,不要一口气把整段代码粘进去再祈祷。尤其是序列化部分,StartElement和EndElement必须成对,命名空间必须和契约一致;发送部分则要区分“HTTP 成功”和“业务成功”,前者只说明请求到了服务端,后者要看RpcResult或错误信息。
3.1 函数签名与连接器创建
先保留示例的入口形态,但把终结点规范化:如果调用方传进来的地址带?wsdl,在函数内部剥掉,避免后面每个参数都跟着错。
CString CSoapDemoDlg::BeginSoap(CString UserName, CString Password, CString WebUrl) { HRESULT hr = S_OK; ISoapConnectorPtr connector; // 调用端点通常不带 ?wsdl,这里先做一次规范化 CString endpoint = WebUrl; int wsdlPos = endpoint.Find(_T("?wsdl")); if (wsdlPos >= 0) { endpoint = endpoint.Left(wsdlPos); } try { hr = connector.CreateInstance(__uuidof(HttpConnector30)); if (FAILED(hr)) { return _T("CreateInstance failed"); } connector->Property["EndPointURL"] = _variant_t((LPCTSTR)endpoint); hr = connector->Connect(); if (FAILED(hr)) { return _T("Connect failed"); } // 后续序列化和发送在下一段补全 return _T("OK"); } catch (_com_error& e) { CString desc((LPCTSTR)e.Description()); CString msg; msg.Format(_T("COM 0x%08X: %s"), e.Error(), (LPCTSTR)desc); return msg; } }这段代码的逻辑是:先创建HttpConnector30实例,把规范化后的地址写入EndPointURL,再调用Connect()准备通道;异常统一用_com_error捕获,把 HRESULT 和描述带回界面。参数说明上,WebUrl是调用端点,不是 WSDL 展示地址;UserName、Password在这里没有参与请求体构造,如果服务端要求鉴权,需要另加 SOAP Header 或传输层配置。CreateInstance失败先查组件注册和位数,Connect失败先查地址、端口、防火墙和服务是否可达,不要一上来就怀疑 XML。
endpoint.Find(_T("?wsdl"))只处理最常见的?wsdl形式,如果地址后面还有?wsdl&x=1,可以用更严格的解析;老接口里我一般只做这一层,避免过度封装把问题藏起来。_variant_t((LPCTSTR)endpoint)是把CString转成 COM 能接收的字符串变体,Unicode 工程下不要用(const char*)强转,否则地址里带中文或特殊字符时会出现截断。
3.2 序列化 SOAP Body:StartElement/WriteString 的顺序与命名空间
序列化段是整段代码的核心。SoapSerializer30负责把方法名、参数名和参数值写成 XML;StartEnvelope开信封,StartBody开消息体,StartElement开元素,WriteString写文本,EndElement关元素。顺序错了,服务端看到的 XML 结构就不对。下面这段按示例方法send构造四个参数,实际参数值不要写真实手机号,用占位符或从界面读取。
ISoapSerializerPtr serializer; serializer.CreateInstance(__uuidof(SoapSerializer30)); // 把序列化器接到连接器的输入流,后续写入的内容才会进入 SOAP 消息 serializer->Init(_variant_t((IUnknown*)connector->InputStream)); const _bstr_t ns(L"http://soap.example.test/cif/services/SMS"); serializer->StartEnvelope(L"SOAP-ENV", L"", L"UTF-8"); serializer->StartBody(L""); serializer->StartElement(L"send", ns, L"", L""); serializer->StartElement(L"cmpcode", ns, L"NONE", L""); serializer->WriteString(L"CMP_CODE"); serializer->EndElement(); serializer->StartElement(L"phone", ns, L"NONE", L""); serializer->WriteString(L"PHONE_PLACEHOLDER"); serializer->EndElement(); serializer->StartElement(L"content", ns, L"NONE", L""); serializer->WriteString(L"你好"); serializer->EndElement(); serializer->StartElement(L"receivedate", ns, L"NONE", L""); serializer->WriteString(L""); serializer->EndElement(); serializer->EndElement(); // 关闭 send serializer->EndBody(); serializer->EndEnvelope();这段代码的逻辑是按“信封—消息体—方法元素—参数元素—文本—关闭”的顺序生成 SOAP 消息。StartElement的第一个参数是元素名,第二个参数是命名空间,第三个参数在示例里写作NONE,它不是业务值,也不是短信内容,具体形参名要以工程里生成的类型库声明为准;第四个参数是编码相关占位,通常留空。WriteString写入的是元素文本,receivedate留空表示由服务端处理或该字段允许空值,不要为了“看起来完整”随便填日期。
这里有一个必须点明的细节:方法元素和参数元素的命名空间要跟 WSDL 的targetNamespace对齐。示例把?wsdl地址当命名空间用,实际联调时通常应去掉?wsdl,并且确认参数元素到底需不需要命名空间。有的服务端要求参数元素不带命名空间,有的要求同命名空间,有的要求elementFormDefault="qualified",不能凭感觉统一加。我的做法是先用最小请求只发一个参数,确认服务端能识别方法,再逐步补齐参数;如果一开始就发四个参数,返回“参数为空”时很难判断是哪一个元素的问题。
StartEnvelope的第三个参数是编码,示例用UTF-8,中文短信内容能不能正确到达服务端,和这里、工程字符集、WriteString的字符串类型都有关系。Unicode 工程里直接写L"你好"通常没问题,但不要把它先转成char*再写,否则可能变成问号。参数顺序也要按 WSDL 或服务端要求来,虽然有些 SOAP 服务端不关心同级元素顺序,但老接口经常按顺序解析,顺序错了就返回空结果。
3.3 发送、读响应与 CString 返回值处理
消息构造完后,EndMessage()才真正把请求发出去。发送成功后,SoapReader30从连接器的输出流读响应,RpcResult是正常返回结果,异常时则要看错误描述。示例最后用CString((const char*)Reader->RpcResult->text)直接转,这在 Unicode 工程里是高风险写法,下面改成_bstr_t中转,避免 BSTR 被char*截断。
hr = connector->EndMessage(); if (FAILED(hr)) { return _T("EndMessage failed"); } ISoapReaderPtr reader; reader.CreateInstance(__uuidof(SoapReader30)); // 读取连接器的输出流;第二个参数是编码提示,留空让组件按响应头处理 reader->Load(_variant_t((IUnknown*)connector->OutputStream), _T("")); CString result; if (reader->RpcResult != NULL) { _bstr_t text(reader->RpcResult->text); result = (LPCTSTR)text; } else { result = _T("no RpcResult"); } return result;这段代码的逻辑是先发送,再创建读取器,把输出流交给Load,最后从RpcResult->text取文本。参数说明上,EndMessage()没有业务参数,返回值只表示发送动作是否完成;Load的第二个参数是编码提示,留空不代表一定自动识别正确,如果响应有中文乱码,再显式指定编码。RpcResult为空不一定是网络失败,也可能是服务端返回了 Fault 或空结果,联调时要把_com_error的描述和 HTTP 状态一起看。
_bstr_t text(reader->RpcResult->text)负责接管 BSTR 的生命周期,result = (LPCTSTR)text在 Unicode 工程里按宽字符取,不会像(const char*)那样按单字节截断。多字节工程要单独确认CString的字符集配置,不要一份代码在两个字符集下硬编。返回给界面前,我一般会做一次空值和长度检查,避免把一整段 XML 错误信息直接弹到用户面前。
3.4 把参数抽成配置:用户名、密码、URL 不写死
示例里地址、参数名和值都写死在函数里,联调时改一次编译一次,很容易把测试值带到生产。更稳的做法是把端点、命名空间、SoapAction和业务参数抽成结构体,函数只负责组装。下面的结构体不追求通用框架,够这个场景用就行。
struct SoapSmsArgs { CString endpoint; // 不带 ?wsdl 的调用地址 CString targetNamespace; // WSDL 的 targetNamespace CString soapAction; // WSDL 里的 soapAction,原样抄 CString cmpCode; // 企业代码或通道代码,按服务端定义 CString phone; // 接收号码,联调时用占位值 CString content; // 短信内容 }; CString MakeSoapAction(const CString& ns, const CString& method) { CString action(ns); if (!action.IsEmpty() && action[action.GetLength() - 1] != _T('/')) { action += _T('/'); } action += method; return action; }这段代码的逻辑是把易变配置从调用代码里剥离,MakeSoapAction只做常见的“命名空间加斜杠再拼方法名”,不保证适用于所有服务端;如果 WSDL 里的soapAction是namespace#method或带引号,直接原样配置,不要用这个函数硬拼。参数说明上,endpoint不包含?wsdl,targetNamespace不包含方法名
本文还有配套的精品资源,点击获取