ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC调用WebService实战:手拼SOAP与WSDL生成桩代码全解析

VC调用WebService实战:手拼SOAP与WSDL生成桩代码全解析

简介:一份面向VC++开发者的Web Service调用示例文档,演示如何通过COM组件与SOAP协议对接远程短信服务接口。文档聚焦ISoapConnector、ISoapSerializer等核心接口的使用,涵盖EndPointURL设置、SOAP Action定义、序列化构造XML消息、参数传递、消息发送及响应读取等关键环节,并附有BeginSoap函数的完整代码片段,代码中包含命名空间、cmpcode、phone等参数的具体构造写法,便于直接对照修改与复用。压缩包内为单个DOC文档,体积仅28KB,内容精炼,适合已有C++基础、希望在MFC/VC++项目中集成Web服务的初中级开发者快速上手。目前已有179人学习浏览,说明该示例对同类需求具有一定参考价值。借助这份文档,读者可以快速理解VC++调用Web Service的整体流程,掌握用HttpConnector30创建连接器、用SoapSerializer30构建SOAP报文、以及通过EndMessage发送请求并读取RpcResult响应的具体写法,减少在COM接口调用与SOAP协议细节上的摸索时间。

1. VC调用WebService,为什么老客户端改造绕不开这门手艺

老项目不会因为你用了新框架就消失。很多企业内部系统还在用VC6时代的原生客户端,服务端接口却早换了好几轮,接口地址变了、协议版本变了,连报文里的命名空间都对不上。新同事接手之后,第一件事往往是把那个已经失效的WebService地址改掉,再顺带解决“最近老是超时”的问题。VC调用WebService这门手艺的价值就在这:不动编译环境,不装额外运行时,把SOAP报文通过系统网络接口发出去,再把返回的XML字段抠出来。这篇笔记给正在改造老客户端的人,一条从原理到最小代码再到排错路径的完整线路。

2. 动手前先选路线:VC调用WebService的三种做法和取舍

2.1 第一种做法:手拼SOAP报文并用系统网络接口直连

手拼SOAP是理解整个调用链最直接的方式。做法很直白:先用字符串把整个XML请求拼出来,用HTTP POST发给服务端,再把返回的XML解析一遍。优点是依赖最少,只要编译器和系统库就能跑,不用引第三方包,也不用担心工具生成的代码跟工程字符集打架。缺点也同样明显——接口字段一多,字符串拼接的位置就容易算错,服务端升级一个字段,就得在代码里翻找对应位置。

很多老的WebService服务端还是SOAP 1.1,HTTP头里必须带SOAPAction,而且要求值是一个完整的方法定位串。拼报文时如果把方法名写错一个字母,服务端返回的错误往往是“找不到指定的方法”,而不是直白的“参数写错了”,排错路径就比较绕。所以手拼方案适合两种人:一种是只调一两个接口、工期紧、不想引入额外依赖;另一种是想把SOAP机理彻底搞明白、为后面排查问题打底子的人。

2.2 第二种做法:用WSDL生成桩代码,把通信细节交给工具

第二种做法是找WSDL接口定义文件,用它生成C++客户端桩代码。WSDL是WebService的“契约”,里面描述了这个服务提供哪些方法、每个方法有哪些参数、参数类型是什么、返回结构长什么样。生成桩代码后,业务代码只面对一个个普通的C++函数,函数名、参数结构体、返回值都对应服务端的定义,SOAP信封、序列化、命名空间这些细节全部交给桩代码去做,这是目前最省力的主流做法。

但是用生成器也有隐性成本。工具生成的代码文件多,而且它默认的字符集处理方式跟老VC工程不一定兼容,编译阶段经常要调一堆宏。生成代码里还会带一些开源协议声明,公司内部合规一般能过,但如果你是给外部客户交付的库,要提前跟法务确认一遍,别在交付文档里留个不清不白的第三方声明。

2.3 第三种做法:桥接层加二次封装,适合多接口长期维护

第三种是前两种基础之上再加一层。做法是定义一个本地业务接口(纯虚类),比如叫IUserService,它只有GetUserInfo、UpdatePassword这样的方法,参数和返回类型全部用工程内的数据类型。在这个纯虚类的实现里,内部再去完成SOAP调用。业务层不接触XML,不接触SOAPAction,也不接触命名空间,改协议时只动这一层。

这套玩法适合那种“这个组件要维护很多年、后续还会对接新系统”的场景。服务端即便是把SOAP换成REST,只要桥接层把请求生成和解析的逻辑内部消化掉,上层代码几乎不用动。代价是前期要多写一层抽象,而且抽象得不好就等于白写——接口粒度要够粗,方法命名要贴近业务,不能把“调用某WebService的某方法”都当成独立接口暴露出去。

2.4 三种做法的对比与默认建议

做法依赖前期成本接口变更成本适合场景
手拼SOAP仅系统库中逐个改一两个接口,马上疏通
生成桩代码工具只在生成期使用低重新生成成批接口,契约稳定
桥接层封装需要与业务编译一致高内部消化跨系统长期维护

我的默认建议是:时间紧、接口少,直接走第一种;接口超过三个、服务端文档完整,用第二种;如果这是个会被多个模块复用的组件,那无论如何都得补第三层。后文先用最直白的第一种讲通原理,再讲怎么用生成工具把体力活省掉。

3. 最小可跑方案:VC手拼SOAP并解析返回报文的完整代码

3.1 先拼出符合服务端预期的SOAP信封

先写一个构造请求报文的函数。参数是业务要的服务端令牌和用户ID,输出是一段完整SOAP请求字符串。这里用std::string而不是C风格数组,是为了避免缓冲区溢出,老代码里习惯用sprintf写死一个大缓冲,一旦参数字段变长就会出现越界写。

// 构造 SOAP 请求报文 // 变量带 web 前缀表示是调用方业务命名空间, 别与 soap 前缀混用 bool BuildSoapRequestBody( const std::string& strToken, const std::string& strUserId, std::string& strOut) { char szBuf[4096] = {0}; // encoding 必须保留 UTF-8, 后面发送和接收都按这个编码走 int nLen = sprintf_s(szBuf, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" "<soap:Envelope " "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" " "xmlns:web=\"http://tempuri.org/\">" "<soap:Header>" "<web:AuthInfo><web:Token>%s</web:Token></web:AuthInfo>" "</soap:Header>" "<soap:Body>" "<web:GetUserInfo>" "<web:UserId>%s</web:UserId>" "</web:GetUserInfo>" "</soap:Body>" "</soap:Envelope>", strToken.c_str(), strUserId.c_str()); if (nLen <= 0) return false; strOut.assign(szBuf, nLen); return true; }

这里的命名空间前缀“soap”和“web”是可以随便改的,关键是xmlns后面的URI值必须与服务端一致,前缀本身只是别名。很多人图省事把前缀改掉,服务端校验严格时直接拒绝,即使不拒绝,返回报文里可能也会带一套自己的前缀,后续解析又得再适配一遍。另外,参数值里的&、<、>这些字符必须先做XML转义,否则服务端解析直接失败,返回一个空白的SOAP Fault,这种错从服务端日志里几乎看不出来。

3.2 用系统网络接口发POST请求:超时、代理与Content-Type

请求报文拼好,下一步是发出去。发送使用系统网络接口的WinHTTP系列函数,这是老VC工程里不需要额外运行时就能用的HTTP客户端接口。下面代码是一次同步调用的主体框架,重点看请求头和超时处理。

#include <winhttp.h> #include <string> #pragma comment(lib, "winhttp.lib") // 发送 SOAP 请求并接收完整响应 bool HttpPostSoap( const std::wstring& strHost, const std::wstring& strPath, int nPort, const std::string& strSoapAction, const std::string& strBody, std::string& strResponse) { // 1. 建立会话句柄 // 显式关闭自动代理, 很多奇怪超时都来自代理链 HINTERNET hSession = WinHttpOpen(L"VC-SOAP-Client/1.0", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (!hSession) return false; // 2. 连接服务端 HINTERNET hConnect = WinHttpConnect(hSession, strHost.c_str(), nPort, 0); if (!hConnect) { WinHttpCloseHandle(hSession); return false; } // 3. 打开请求, 注意 HTTPS 时带 WINHTTP_FLAG_SECURE HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", strPath.c_str(), NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, nPort == 443 ? WINHTTP_FLAG_SECURE : 0); if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return false; } // 4. 设置 Content-Type 和 SOAPAction // SOAPAction 加不加引号要参考服务端习惯, 默认加引号最稳 std::string strHeaders = "Content-Type: text/xml; charset=utf-8\r\n"; strHeaders += "SOAPAction: \"" + strSoapAction + "\"\r\n"; // 5. 发送请求体 BOOL bSend = WinHttpSendRequest(hRequest, strHeaders.c_str(), (DWORD)strHeaders.size(), (LPVOID)strBody.c_str(), (DWORD)strBody.size(), (DWORD)strBody.size(), 0); if (!bSend) { WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return false; } // 6. 接收响应, 按块读取直到结束 BOOL bReceive = WinHttpReceiveResponse(hRequest, NULL); if (!bReceive) { WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return false; } DWORD dwSize = 0; do { dwSize = 0; if (!WinHttpQueryDataAvailable(hRequest, &dwSize)) break; if (dwSize == 0) break; std::string strBuf; strBuf.resize(dwSize); DWORD dwRead = 0; WinHttpReadData(hRequest, (LPVOID)&strBuf[0], dwSize, &dwRead); strResponse.append(strBuf.c_str(), dwRead); } while (dwSize > 0); WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return true; }

这段代码里几个参数值得单独说明。Content-Type必须是text/xml或application/soap+xml,不能是表单类型,很多老接口对Content-Type校验很死,类型不对还没走到业务逻辑就被网关挡了。SOAPAction的值直接决定服务端能否正确路由方法,我建议从WSDL里复制原值,不要凭记忆手敲。如果服务端用的是SOAP 1.2,Content-Type应换成application/soap+xml,SOAPAction头可以省略,但许多老服务端只认SOAP 1.1,切之前先确认清楚。

超时这里没有单独设置,用的是默认值。默认值在部分系统上比较保守,外部网络故障时会干等很久。建议在WinHttpSendRequest之前用WinHttpSetTimeouts把连接超时设成5秒、接收超时设成10秒,让问题尽早暴露。

3.3 用XML解析组件解析返回值:XPath定位与类型转换

响应报文拿到手后,不要用字符串查找的方式去抠数据。SOAP响应里可能有CDATA、可能有额外空白、可能有不同前缀,字符串查找非常脆弱。常见做法是交给系统自带的XML解析组件,先加载整段XML,再用XPath定位节点。

#include <msxml.h> #pragma comment(lib, "ole32.lib") // 解析 SOAP 响应, 提取 GetUserInfoResult 下的 Name 节点文本 bool ParseSoapResponse(const std::string& strXml, const std::string& strFieldName, std::string& strValue) { // 每个线程独立初始化一次 COM, 多次调用可放到入口统一执行 ::CoInitialize(NULL); IXMLDOMDocument* pDoc = NULL; HRESULT hr = CoCreateInstance( CLSID_DOMDocument, NULL, CLSCTX_INPROC_SERVER, IID_IXMLDOMDocument, (LPVOID*)&pDoc); if (FAILED(hr)) { ::CoUninitialize(); return false; } // 把响应的窄字符串转成宽字符串后交给解析器 std::wstring strWide; // 这里省略具体转换函数, 用 MultiByteToWideChar 从 UTF-8 转过来 strWide = Utf8ToWide(strXml.c_str()); VARIANT varXml; VariantInit(&varXml); varXml.vt = VT_BSTR; varXml.bstrVal = ::SysAllocString(strWide.c_str()); // 注意: loadXML 的返回值和预期相反, 要仔细判断 VARIANT_BOOL bLoaded = VARIANT_FALSE; pDoc->loadXML(varXml, &bLoaded); // 拼接 XPath, 用 local-name 忽略命名空间前缀 std::string strXPath = "//*[local-name()='GetUserInfoResult']/*[local-name()='" + strFieldName + "']"; IXMLDOMNode* pNode = NULL; if (pDoc->selectSingleNode(_bstr_t(strXPath.c_str()), &pNode) == S_OK) { BSTR bstrText = NULL; pNode->get_text(&bstrText); // 再把宽字符串转回业务层使用的编码 strValue = WideToUtf8(bstrText); SysFreeString(bstrText); if (pNode) pNode->Release(); } VariantClear(&varXml); if (pDoc) pDoc->Release(); ::CoUninitialize(); return !strValue.empty(); }

这里最容易踩的坑是XPath里的前缀问题。返回报文里的业务节点往往没有前缀,但外面套了几层默认命名空间,你如果用带前缀的XPath去查,解析器根本不知道这个前缀指向哪个URI,自然查不到。用local-name()忽略前缀是省事解法,代价是性能差一点,不过单次SOAP调用的响应体很小,这点性能完全无所谓。

3.4 把这个调用串进VC工程的三处配置

第一处是字符集。工程属性里的“字符集”建议保持“使用多字节字符集”,因为WinHTTP和XML解析的窄宽转换已经够多,再叠一层Unicode工程的_T宏会把代码全部搅乱。如果你的工程只能Unicode,那就把业务层统一成std::wstring,只在拼报文前转成UTF-8的窄串。

第二处是依赖库。WinHTTP需要链接winhttp.lib,XML解析需要ole32.lib,加上前面的两条pragma comment就行。老编译器如果pragma comment不生效,就在项目连接器附加依赖项里手动补上。

第三处是调用收敛。把上面三个函数放进一个独立的SOAPClient.cpp文件,头文件只暴露一个总入口,例如调用成功返回true并把业务字段传出来。不要把拼报文、发送、解析散落在各个窗口类里,否则接口一多,改一个字段要动好几处。

4. 换一条省事的路:从WSDL生成桩代码并完成一次本地联调

4.1 拿WSDL和生成工具生成头文件与客户端桩

服务端一般会给WSDL地址,常见是接口地址后面加?wsdl,或者通过服务管理页面下载。拿到WSDL先别急着生成,打开文件搜一下“binding”和“service”这两段,缺了说明文档不完整,先让服务端同事确认。再搜“soapAction”看一遍所有方法的路由写法,心里有数。

用某开源SOAP工具生成时,常见是两个阶段:先把WSDL转成契约头文件,再根据头文件生成客户端桩代码。示意命令如下:

# 示意命令, 具体参数以你下载工具的文档为准 wsdl2cpp -o service.h service.wsdl soapstub -C -I service.h -o service_client.cpp

生成结果通常包含几类文件:契约头文件(方法声明和结构体)、路由映射文件(服务地址与命名空间对应)、客户端实现文件(请求发送与接收逻辑)、以及通用编解码源文件。把这些文件全部加进工程后,先编译一次,优先解决字符集和宏定义冲突,别急着写业务调用。

4.2 把桩代码编进工程:需要注意的编译宏与链接库

先统一文件编码。生成工具输出的文件可能是UTF-8带BOM或UTF-8无BOM,如果工程用的是本地代码页,编译时会出现一堆杂乱的新行警告。常见做法是把生成文件另存为与工程一致的编码,或者在文件开头插入编译选项处理。

第二件事是关闭预编译头对桩代码的影响。桩代码文件内容频繁变动,放进预编译头后,改动生成文件可能因头文件缓存未更新而出现“改了没生效”的诡异现象。常见做法是生成文件单独编译,不使用预编译头。

字符集方面,桩代码接口参数往往默认用窄字符串,如果工程是Unicode,不要在生成文件里大改,而是在业务调用处做转换。宏方面,如果工具默认支持多线程,确认工程里对应的运行时库设置是MT还是MD,两者混用会引出链接错误。

4.3 联调中先看四层日志:按这个顺序查最快

第一次跑通生成桩代码,不要一上来就在业务层打断点。建议按下面四层去抓数据,每一层都有不同的判断依据:

层次抓什么怎么判断
请求信封方法名、命名空间URI、参数节点顺序方法名多打或少打一个字母,这里最先暴露
HTTP请求头Content-Type、SOAPAction、HostSOAPAction和Content-Type不符合时,服务端会提前拒绝
HTTP响应状态200之外的错误码500是服务端处理异常,404是地址错,401要补鉴权
服务端返回体Fault节点、业务结果节点先找Fault再找业务节点,两个都为空说明报文结构不对

抓法上,常见做法是给桩代码加一个回调钩子,把请求和响应原文输出到日志;不方便改桩代码时,就用抓包软件看TCP流,本地回环也抓得到。我一般先在服务端日志里看请求有没有进来,再看请求内容,能快速判断问题在网络层还是业务层。

5. VC调用WebService常见问题排查:五类坑与对应解法

5.1 返回节点取不到值:命名空间前缀的陷阱

现象:请求发送成功,服务端返回200,代码里解析出来的字段却全是空。

原因:返回XML里最常见的格式是<GetUserInfoResult>,它处在一个默认命名空间下,比如xmlns="http://tempuri.org/"。你用带前缀的XPath去查,例如/soap:Envelope/soap:Body/web:GetUserInfoResult,前缀没有在解析器里注册,自然查不到。

解决:两种办法二选一。一是给解析器注册这个默认命名空间,再在XPath里用前缀;二是放弃前缀,用//*[local-name()='GetUserInfoResult']这种忽略前缀的写法。我倾向第二种,虽然多遍历几层,但老系统里字段少,完全够用,而且服务端改前缀也不影响解析。

5.2 中文变乱码:字符集声明和真实编码不一致

现象:代码里赋的是“张三”,服务端存到数据库却变成“???”,或者服务端返回的中文到客户端变成“锟斤拷”。

原因:请求报文里写了encoding="UTF-8",拼字符串时用的却是本地代码页,发送时又没转换,服务端按UTF-8解码,自然乱。返回时同理,服务端按UTF-8发,客户端直接按本地代码页解释。

解决:统一字符集。拼报文之前把参数统一转成UTF-8,收到响应后也先确认真实编码再转成界面字符集。注意在完整报文层做一次转换,别对半截字符串转,很多半吊子代码就是在这里翻车的。

5.3 调用报“方法不存在”:SOAPAction与包体不一致

现象:请求发出去,服务端返回SOAP Fault,错误描述类似“方法GetUserInfo不存在”,但WSDL文档里明明有这个操作。

原因:服务端方法定位依赖两个信息:SOAPAction头里的字符串,和Body里方法标签的命名空间。只有这两处跟服务端定义一致才能命中。常见情况是SOAPAction写的是tempuri.org下的GetUserInfo,而Body里的方法标签绑的是另一个命名空间URI,前后不统一。

解决:把SOAPAction的值和Body里方法标签的namespace对齐。最稳的办法是去服务端WSDL文件里搜索“soapAction”,直接复制原值,不要凭记忆敲。顺带把请求体里的方法名和WSDL里的operation name对照一遍。

5.4 请求卡到超时:代理和连接复用的默认行为

现象:同一个接口,用浏览器调试工具秒回,工程里调用却等30秒,最后报超时。

原因:网络组件默认会去读取浏览器代理配置。客户端所在内网如果残留了失效的公网代理,请求先被代理中转一次,等不到响应就一直耗着。

解决:在WinHttpOpen阶段显式指定WINHTTP_ACCESS_TYPE_NO_PROXY直连。如果环境确实需要代理,把代理地址明确写进去,不依赖“自动检测”。同时用WinHttpSetTimeouts把连接超时和接收超时改短,连接5秒、接收10秒是比较合理的起点。

5.5 一开Unicode字符集编译就报错:一揽子兼容写法

现象:工程原本是多字节字符集,代码能跑。后来为了支持中文界面,把工程字符集切到Unicode,结果一堆调用报C2664,错误集中在字符串指针类型不匹配。

原因:网络接口在Unicode字符集下的字符串参数全部变成宽字符,你传进去的char*没有转换就直接被编译拒绝。SOAP的XML报文本身是UTF-8编码,跟Unicode字符集是两回事,不能用_T宏简单包一层就完事。

解决:约定一个边界。业务层内部用std::wstring或窄字符串都可以,但在调用网络接口和XML解析组件之前,显式做窄宽转换。转换函数单独放一个工具文件里,全工程复用。老工程里推荐保持多字节字符集,不要为了界面切Unicode,界面文字在多字节字符集下也能正常显示,却省掉大量转换问题。

6. 上生产前再补三个能力:动态地址、HTTPS证书和异步处理

前面的代码已经能完成一次同步调用,但真正丢到生产环境,几乎都会遇到三个补强需求。

第一,服务地址动态化。老系统的服务端地址经常切,测试环境一个地址、生产环境另一个地址。常见做法是把Host、端口、路径三个部分拆开放进本地配置文件或注册表,程序启动时读一次,而不是在代码里写死。拆要拆彻底,别只把完整URL存成字符串,否则改配置的人还得手工去解析那段URL。动态地址还要考虑接口升级后URL前缀变化的情况,把路径作为配置项而不是常量,能少改一次代码。

第二,HTTPS证书处理。服务端上了HTTPS,如果是内部自签证书,网络组件默认校验失败。这时候要区分环境:内网测试环境可以临时跳过证书校验,生产环境必须严格走证书链校验,并让运维把根证书部署到客户端机器。顺手写一个只用于开发环境的开关,发布生产时强制关闭。跳过校验的代码只是敲门砖,别让它带着上生产线,否则中间人攻击的锅就让你这边担上了。

第三,异步调用。UI程序如果在界面线程里直接发同步POST,界面会卡死几秒。常见做法是把调用放进工作线程,线程结束后用消息或回调切回UI线程更新界面。如果工程用MFC,可以用PostMessage把结果送回窗口;如果用纯SDK窗口,就post一个自定义消息。注意工作线程里也要单独做一次COM初始化,每个线程的COM环境是独立的,主线程初始化不会自动继承给子线程。

补强项关键做法注意点
动态地址Host/端口/路径拆分到配置别把完整URL存成字符串
HTTPS证书测试临时忽略,生产严格校验忽略选项不能上生产
异步调用工作线程加消息回主线程子线程单独初始化COM

一个实际教训。我之前给某图像处理Demo接WebService,服务端地址末尾多了一个斜杠,开发环境不报错,上生产却总是404。后来逐一排查才发现,测试环境的网关把斜杠吞掉了,生产环境网关严格校验。那次之后我养成了一个习惯:无论用手拼SOAP还是工具生成桩,第一版就先让完整请求报文和响应报文各存一份日志,配上时间戳。一旦线上出问题,先看报文文本,再看网络,大多数玄学问题其实都能在报文里找到答案,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进