简介:这份面向 VC++ 开发者的技术文档,聚焦在本地应用中通过 COM 接口与 SOAP 协议调用远程 Web Service 的典型场景,适合需要完成短信接口对接、遗留系统集成或补强 SOAP 通信理解的初中级程序员参考。压缩包内共 1 个 doc 文件,约 28KB,内容以可复用的代码示例和关键接口说明为主,便于在工程中对照查阅。文档围绕 HttpConnector30、SoapSerializer30、SoapReader30 等组件展开,覆盖连接地址设置、SoapAction 指定、消息封装、参数写入与响应读取等环节,并给出 BeginSoap 函数的实现片段,可帮助读者理解从建立连接到解析 RpcResult 的完整调用链。目前已有 179 人学习,适合作为 VC++ 调用 Web Service 的入门样例与排错参考。
1. 老 MFC 工程要发短信:VC 里调 Web Service 只剩两条路
维护过老 MFC 工程的同行大概都遇到过这种场面:业务侧丢过来一个 WSDL 地址,要求桌面端直接调远程接口——发条短信、查个工单状态、推一次设备心跳。桌面程序是 VC6 或 VS2008 时代的 MFC 代码,没有服务引用,也走不通“生成代理类”那条顺路,客户机上还未必装得上新的运行时。能落地的路线其实就两条:自己拼 SOAP XML 用 WinHTTP 发出去,或者用 SOAP Toolkit 3.0 那套 COM 组件。
后者把传输、序列化、解析拆成三个接口对象,几百行就能跑通一次调用,属于老派 programming 里比较省事的一档。这段代码值得逐行拆开看:它在干什么、哪几行是硬伤、参数怎么改、失败时先看哪儿。适合手里压着 MFC/ATL 老项目、要对接 SOAP 1.1 over HTTP 接口、又不方便引入新运行时的人。
2. SOAP Toolkit 的组件模型:三个 COM 对象各管一段
理解这段代码的前提,是先接受一个事实——SOAP 调用在协议层就是“一次 HTTP POST,body 是一段 XML”。所有封装都是围绕这句话做的。SOAP Toolkit 3.0 没有把这件事包成一个函数,而是拆成三个 COM 对象,各自负责一段流水线:Connector 管连接和收发,Serializer 管把方法调用写成 XML,Reader 管从响应流里把返回值抠出来。搞清这三段各自持有什么状态,排错时才知道该在哪一段下断点。
2.1 Connector 持连接、Serializer 写请求、Reader 读响应
HttpConnector30实现的是ISoapConnector,它的职责很窄:持有一个 EndPointURL、一个 SoapAction、一对输入输出流,然后负责把流里的字节 POST 出去、把响应字节写回另一条流。注意它自己不认识 XML,你给它什么字节它就发什么字节。
SoapSerializer30实现ISoapSerializer,本质是一个挂在输出流上的 XML 写入器。StartEnvelope开头,EndEnvelope收尾,中间用StartElement/WriteString/EndElement一层层往下钻。它不知道业务语义,只知道“现在要开一个带命名空间的元素”。
SoapReader30实现ISoapReader,挂在 Connector 的OutputStream上。Load之后它会解析整段响应,RpcResult指向第一个返回结构,Body拿到整个 Body 节点,Fault拿到 SOAP Fault。三者串起来的关键是下面这行转换:
// SoapConnector->InputStream 是 IStream* 类型的属性, // 需要先转成 IUnknown* 再包进 _variant_t,Serializer 才能接管这条流 Serializer->Init(_variant_t((IUnknown*)SoapConnector->InputStream));直接写_variant_t(SoapConnector->InputStream)多数编译器会报重载歧义,因为_variant_t的构造函数对接口指针不友好,必须先降级到IUnknown*。这是这套 API 最容易被忽略的一处细节,很多人第一次编译不过就卡在这里。同理,Reader->Load(_variant_t((IUnknown*)SoapConnector->OutputStream), _T(""))的第二个参数是编码提示,留空表示让解析器自己看 XML 声明。
提示:Connector 的
InputStream是“客户端要发出去的流”,OutputStream是“服务端回来写进来的流”,方向别记反。Serializer 挂 Input,Reader 挂 Output。
对象的生命周期也值得说一句。上面三个都是智能指针(ISoapConnectorPtr这类带Ptr后缀的_com_ptr_t包装),离开作用域自动Release。但 Connector 必须在 Serializer 和 Reader 都释放之后再销毁,否则流被提前抽掉,析构阶段容易出现访问违例。稳妥写法是让 Connector 在最外层作用域声明,或者在函数尾部显式SoapConnector.Release()之前先置空另外两个。
2.2 WSDL 里必须一字不差抄下来的三个值
这套 API 最反直觉的地方在于:它不做 WSDL 解析。你在代码里写的 EndPointURL、命名空间、SoapAction,跟服务端定义对不上,它不会帮你纠正,只会在运行时给你一个含糊的失败。所以第一步永远是把 WSDL 打开,抄三个值。
<definitions targetNamespace="http://sms.example.com/" xmlns:tns="http://sms.example.com/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"> <service name="SmsService"> <port name="SmsPort" binding="tns:SmsBinding"> <!-- 第一个值:EndPointURL。注意不带 ?wsdl --> <soap:address location="http://192.0.2.10:8080/cif/services/SMS"/> </port> </service> <portType name="SmsPortType"> <operation name="send"> <input message="tns:sendRequest"/> </operation> </portType> <binding name="SmsBinding" type="tns:SmsPortType"> <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/> <operation name="send"> <!-- 第二个值:SoapAction,整串照抄,包括引号里的斜杠结尾 --> <soap:operation soapAction="http://sms.example.com/send"/> </operation> </binding> </definitions>三个值分别是:
| 值 | 从哪抄 | 用在哪 | 常见误写 |
|---|---|---|---|
| EndPointURL | soap:address的location | SoapConnector->Property["EndPointURL"] | 尾部多写?wsdl |
| 命名空间 | definitions的targetNamespace | StartElement的第二个参数、StartEnvelope的第二个参数 | 拿 URL 路径当命名空间 |
| SoapAction | soap:operation的soapAction | SoapConnector->Property["SoapAction"] | 手工拼成“命名空间+方法名”,尾部带空格 |
第三行是重灾区。原始代码里那句char *szNameSpace = "http://.../cif/services/SMS/";后面带了个斜杠,再拼_bstr_t("send"),得到的 action 是.../SMS/send,看着挺对。但一旦命名空间实际是http://sms.example.com/,这个拼法就完全错了。正确做法是照抄 WSDL 里的soapAction整串,不要自己拼。有些服务端确实用“命名空间 + 方法名”的约定,但那是它的约定,不是协议规定,抄比推可靠。
还有一个隐藏点:?wsdl这个后缀。浏览器里打开http://host/path?wsdl能看到 WSDL,很容易让人以为这就是服务地址。实际soap:address里的 location 通常不带?wsdl。带上之后,一部分网关会原样返回 WSDL 文档当响应体,Reader 解析出来的RpcResult是空的或者干脆报错,你盯着代码看半天也找不出毛病。
2.3 一次调用的最小骨架
把上面的东西填进原始函数,去掉业务参数,骨架大致是这样:
#import "mssoap30.dll" no_namespace named_guids // 引入类型库,才有 HttpConnector30 等 CLSID // 三个常量全部从 WSDL 抄,不要自己拼 const LPCTSTR kEndPoint = _T("http://192.0.2.10:8080/cif/services/SMS"); const LPCTSTR kNameSpace = _T("http://sms.example.com/"); const LPCTSTR kSoapAction = _T("http://sms.example.com/send"); CString CWebservice_vcDlg::BeginSoap(CString UserName, CString Password, CString WebUrl) { HRESULT hr = S_OK; ISoapConnectorPtr SoapConnector; ISoapSerializerPtr Serializer; ISoapReaderPtr Reader; try { // 1. 建连接器:HttpConnector30 是 HTTP 传输实现 SoapConnector.CreateInstance(__uuidof(HttpConnector30)); SoapConnector->Property["EndPointURL"] = (_bstr_t)kEndPoint; SoapConnector->Property["SoapAction"] = (_bstr_t)kSoapAction; // 2. 建连。注意这一步只建立传输层连接,不校验命名空间 hr = SoapConnector->Connect(); if (FAILED(hr)) return _T(""); // 3. 开消息,拿到输入流的写入权 SoapConnector->BeginMessage(); // 4. Serializer 挂到 Input 流上,开始写信封 Serializer.CreateInstance(__uuidof(SoapSerializer30)); Serializer->Init(_variant_t((IUnknown*)SoapConnector->InputStream)); Serializer->StartEnvelope(_bstr_t("SOAP-ENV"), _bstr_t(kNameSpace), _bstr_t("UTF-8")); Serializer->StartBody(_bstr_t("")); // 5. 方法节点:名字、命名空间、前缀、空属性 Serializer->StartElement(_bstr_t("send"), _bstr_t(kNameSpace), _bstr_t(""), _bstr_t("")); Serializer->StartElement(_bstr_t("cmpcode"), _bstr_t(kNameSpace), _bstr_t(""), _bstr_t("")); Serializer->WriteString(_bstr_t("0166")); Serializer->EndElement(); Serializer->EndElement(); Serializer->EndBody(); Serializer->EndEnvelope(); // 6. 真正发出去 hr = SoapConnector->EndMessage(); if (FAILED(hr)) return _T(""); // 7. Reader 挂到 Output 流上,取返回值 Reader.CreateInstance(__uuidof(SoapReader30)); Reader->Load(_variant_t((IUnknown*)SoapConnector->OutputStream), _T("")); return CString((const char*)Reader->RpcResult->text); } catch (_com_error& e) { return CString((const char*)e.Description()); } }逐段说逻辑。第 1 步的CreateInstance走的是 COM 类厂,__uuidof(HttpConnector30)依赖#import生成的声明,所以#import那一行不能少,且必须放在使用之前。第 2 步的Connect只做传输层连接,服务端命名空间对不对,它管不着。第 3 步BeginMessage会重置输入流,为本次写入腾位置——这一步漏了,第二次调用会带着上一次的残留内容。
第 5 步的StartElement四个参数依次是元素名、命名空间、前缀、属性占位。前缀传空串表示用默认命名空间,服务端能认;但如果对方是 RPC 风格且要求tns:前缀,就得跟前缀绑定一致。原始代码里元素用的是"NONE"前缀,这基本是随手写的,多数服务端不校验前缀本身,但严格实现会拒。
第 7 步RpcResult->text只取第一个返回节点的文本。返回结构有多个字段时,要遍历RpcResult->Children,或者直接读Reader->Body的 XML 再自己解析。这是个常见盲区,很多人拿到空串以为调用失败,其实是取错了节点。
3. 把原始函数改造成可复用客户端:参数、编码、错误一处不漏
原始那段代码能跑通说明链路是通的,但它把三个东西糅在一起:业务参数(cmpcode、phone、content)、协议参数(命名空间、action)、传输参数(URL)。任意一个改动都得动函数体,而且出错时只返回空串,什么线索都不给。要真正能用,得把它拆成“配置 + 一次调用 + 结果”三块,参数走数组,错误走返回码。
3.1 原始代码里六处会让人反复翻车的地方
逐条对照着看,这几处不改,接第二个接口时还会再栽一遍。
命名空间被写成了 URL 路径。原始代码里StartElement("send", "http://.../cif/services/SMS?wsdl", ...)把完整的服务地址当命名空间用了。命名空间是逻辑标识符,跟物理地址没关系,它来自 WSDL 的targetNamespace。这两者写反了,服务端会返回 “unable to find endpoint dispatch” 之类的错误,或者更糟——返回 HTTP 200 但 Body 里是 Fault。
EndPointURL 带了?wsdl。已经有服务端会因此返回描述文档而不是执行结果,客户端拿到一堆<definitions>去调RpcResult,取出来是空指针。
SoapAction 手工拼接。szNameSpace + _bstr_t("send")里szNameSpace尾部有空格(原始代码的字符串字面量里就带着),拼出来是"...SMS/ send",多一个空格。HTTP 头对空格敏感,服务端比对 action 失败就是 500。
返回值只有空串。if (FAILED(hr)) return " ";和catch (_com_error e) { return e.Description(); }把两类完全不同的错误——HRESULT 失败和 COM 异常——压成同一个字符串,调用方没法区分“网络断了”和“服务端拒绝了”。
CString 转换在 Unicode 工程里会坏。CString((const char*)Reader->RpcResult->text)在_UNICODE编译下会把宽字符的BSTR强转成char*再交给 CString,中文直接变成乱码或截断。正确写法是先转到_bstr_t,再交给 CString 的宽字符构造。
没管 COM 初始化。整个函数依赖 COM,但CoInitialize/CoInitializeEx在 MFC 里通常由AfxOleInit()代劳;如果这个函数被放到工作线程里调用,线程上没初始化 COM,CreateInstance直接返回CO_E_NOTINITIALIZED。
3.2 拆成 CSoapClient:配置一次,调用多次
改造思路很简单——端点、命名空间、action 作为成员变量,方法名和参数作为调用的入参,错误码和错误描述分开返回。
// SoapClient.h #pragma once #import "mssoap30.dll" no_namespace named_guids class CSoapClient { public: CSoapClient(LPCTSTR lpszEndPoint, LPCTSTR lpszNameSpace, LPCTSTR lpszSoapAction); ~CSoapClient(); // names/values 一一对应,按顺序写成子元素;strResult 收返回值,strError 收错误描述 bool Invoke(LPCTSTR lpszMethod, const CStringArray& names, const CStringArray& values, CString& strResult, CString& strError); private: CString m_strEndPoint; CString m_strNameSpace; CString m_strSoapAction; };// SoapClient.cpp CSoapClient::CSoapClient(LPCTSTR lpszEndPoint, LPCTSTR lpszNameSpace, LPCTSTR lpszSoapAction) : m_strEndPoint(lpszEndPoint) , m_strNameSpace(lpszNameSpace) , m_strSoapAction(lpszSoapAction) { } CSoapClient::~CSoapClient() { } bool CSoapClient::Invoke(LPCTSTR lpszMethod, const CStringArray& names, const CStringArray& values, CString& strResult, CString& strError) { if (names.GetSize() != values.GetSize()) { strError = _T("参数名与参数值数量不一致"); return false; } ISoapConnectorPtr conn; ISoapSerializerPtr ser; ISoapReaderPtr reader; try { conn.CreateInstance(__uuidof(HttpConnector30)); conn->Property["EndPointURL"] = (_bstr_t)m_strEndPoint; conn->Property["SoapAction"] = (_bstr_t)m_strSoapAction; // Connect 失败通常是地址不可达或端口不通,先查网络再查代码 if (FAILED(conn->Connect())) { strError = _T("Connect 失败:检查 EndPointURL 与网络连通性"); return false; } conn->BeginMessage(); ser.CreateInstance(__uuidof(SoapSerializer30)); ser->Init(_variant_t((IUnknown*)conn->InputStream)); ser->StartEnvelope(_bstr_t("SOAP-ENV"), _bstr_t((LPCTSTR)m_strNameSpace), _bstr_t("UTF-8")); ser->StartBody(_bstr_t("")); // 方法节点用命名空间,子参数节点用空命名空间继承外层,避免重复声明 ser->StartElement(_bstr_t((LPCSTR)CT2A(lpszMethod)), _bstr_t((LPCTSTR)m_strNameSpace), _bstr_t(""), _bstr_t("")); for (int i = 0; i < names.GetSize(); ++i) { ser->StartElement(_bstr_t((LPCSTR)CT2A(names[i])), _bstr_t(""), _bstr_t(""), _bstr_t("")); ser->WriteString(_bstr_t((LPCTSTR)values[i])); ser->EndElement(); } ser->EndElement(); ser->EndBody(); ser->EndEnvelope(); if (FAILED(conn->EndMessage())) { strError = _T("EndMessage 失败:服务端未正常接收,检查 SOAPAction 与报文结构"); return false; } reader.CreateInstance(__uuidof(SoapReader30)); reader->Load(_variant_t((IUnknown*)conn->OutputStream), _T("")); // 先判 Fault,再取结果。顺序反了会把 Fault 当成功结果返回 if (reader->Fault != NULL) { strError = CString((LPCSTR)reader->Fault->faultstring); return false; } if (reader->RpcResult == NULL) { strError = _T("响应中没有 RpcResult,可能是命名空间不匹配或返回结构非 RPC 风格"); return false; } strResult = CString((LPCSTR)reader->RpcResult->text); return true; } catch (_com_error& e) { // Description 是给人看的,Error() 的 HRESULT 是给日志检索用的 strError.Format(_T("COM 异常 0x%08X: %s"), (unsigned int)e.Error(), (LPCTSTR)CString((LPCSTR)e.Description())); return false; } }两处要注意的实现细节。一是子参数节点的命名空间传了空串——SOAP 里子元素默认继承父元素的命名空间,重复写一遍会让报文变长,个别严格的解析器还会认为是新命名空间。二是Fault的判断必须放在RpcResult之前,服务端拒绝请求时会返回<Fault>,此时RpcResult可能非空但内容是错误码,直接当结果用会把错误吞掉。
CT2A和CString((LPCSTR)...)这类转换在 MBCS 工程下没问题,Unicode 工程下需要换成CW2A。这是老工程常见的编译期坑:同一份代码在两种字符集配置下行为完全不同。
3.3 参数类型与序列化方法的对应表
这套 API 不提供类型推导,WriteString写什么就发什么,服务端按 WSDL 里的类型去解析。所以参数该用哪个函数写,取决于 WSDL 里声明的类型,不取决于你在 C++ 里怎么存。
| WSDL 类型 | 写入方法 | 说明 |
|---|---|---|
xsd:string | WriteString | 内部会做 XML 转义,<、&自动变实体,不用手转 |
xsd:int/xsd:long | WriteString+ 数字字符串 | 该接口没有WriteInt,数字当字符串写即可 |
xsd:boolean | WriteString+"true"/"false" | 写"1"部分服务端不认 |
xsd:dateTime | WriteString+"2024-01-01T00:00:00" | 不带时区,宁可少写也别写错格式 |
xsd:base64Binary | 先自行 Base64 编码再WriteString | 接口不代劳 |
| 空值 / 可选参数 | 用WriteString("")或StartElement+EndElement | 后者生成<x/>,前者生成<x></x>,个别服务端区分 |
注意:
WriteString只处理 XML 特殊字符,不处理 URL 编码,也不处理业务层的转义规则(比如短信内容里的签名分隔符)。这些要在调用前自己处理干净。
另外,参数顺序在 RPC 风格下是有意义的,部分服务端按位置取值而不是按名字。所以数组顺序必须跟 WSDL 里message定义的part顺序一致,乱序会得到一个“参数不匹配”的 Fault,而错误信息通常不会告诉你是哪个参数。
4. 避坑与排查:连接成功却拿不到结果时先看哪儿
SOAP 调用失败最难受的地方是错误分层太多——TCP 层、HTTP 层、SOAP 层、业务层,任何一层出问题,客户端看到的都是一个空串或者一句含糊的描述。按“现象 → 原因 → 解决”的顺序把高频的几条列出来,遇到问题时对着走一遍,比盲改代码快得多。
4.1 现象:Connect 返回成功,但结果为空串
原因通常有两个方向。一是 EndPointURL 带了?wsdl,服务端把 WSDL 文档当响应返回,RpcResult为空;二是命名空间不匹配,服务端解析不出该派发给哪个方法,返回 HTTP 200 但 Body 是空的或只有 Header。
解决:先把 EndPointURL 的查询串去掉,确保它就是soap:address里的 location。再把Reader->Body的原始 XML 打出来看——OutputDEBUGSTRING或者写文件都行。如果 Body 里是一段<definitions>,那基本就是 URL 问题;如果是<soap:Body/>空的,就是命名空间或 action 不匹配。这一步不打日志,后面全靠猜。
4.2 现象:中文内容发出去变成问号,或者服务端收到的乱码
原因在编码链路上。StartEnvelope的第三个参数声明了UTF-8,但WriteString接收的是BSTR(UTF-16),中间由 Toolkit 负责转换。如果 C++ 侧传进来的是 MBCS 下的 GBK 字节流,被当成 UTF-16 处理,就会出乱码。
解决:确认工程字符集。MBCS 工程里,CString转_bstr_t时会按当前代码页转换,只要系统区域设置是中文,通常没问题;Unicode 工程里就不要再走(LPCSTR)强转,直接用_bstr_t(csValue)让编译器选宽字符构造。接收侧同理,Reader->RpcResult->text是BSTR,CString((LPCSTR)...)在 Unicode 配置下会丢字符,换成CString((LPCWSTR)...)或者直接_bstr_t赋值。
4.3 现象:HTTP 500,错误信息里有 “SOAPAction”
原因几乎一定是 SoapAction 的值跟服务端期望的不一致。常见来源有三个:手工拼接时尾部多了空格;命名空间用了 URL 路径而不是targetNamespace;服务端定义了一个跟方法名完全无关的 action URI(比如urn:sendSms),而你按“命名空间+方法名”去拼。
解决:把 WSDL 里<soap:operation soapAction="..."/>的内容整串复制,包括引号内的全部字符,不要做任何拼接。如果 WSDL 里soapAction=""是空的,那就把 Property 设成空串,别传方法名——有些服务端只认空 action。
4.4 现象:主线程能调,放到工作线程里必崩或返回CO_E_NOTINITIALIZED
原因是 COM 没有在该线程初始化。MFC 的AfxOleInit()只初始化主线程(实际是调用OleInitialize),工作线程需要自己调CoInitializeEx。
解决:在每个调用 SOAP 的工作线程入口加初始化,退出前配对释放。
UINT CWorkerThread::ThreadProc(LPVOID pParam) { // SOAP Toolkit 是 STA 组件,用 STA 模型更稳 HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); if (FAILED(hr)) return 1; // ... 在这里调 CSoapClient::Invoke CoUninitialize(); return 0; }STA 模型下,跨线程传递ISoapConnectorPtr是不行的,每个线程各自创建 Connector。有人图省事把连接器做成全局单例,主线程创建、工作线程调用,结果就是随机崩溃——这种问题最像玄学,因为崩的位置每次都不一样。
4.5 现象:开发机正常,客户机上CreateInstance失败,HRESULT 是0x80040154
原因是目标机器上没注册 SOAP Toolkit 的组件,或者注册的是 32 位版本而程序编译成了 64 位。HttpConnector30的实现在mssoap30.dll里,它只有 32 位版本。
解决:先确认进程位数,32 位程序配 32 位组件。注册用regsvr32指向对应目录下的 DLL,注册完在注册表HKCR\HttpConnector30下能看到 CLSID 才算成功。64 位程序这条路走不通,只能改成 32 位编译,或者干脆换成手写 XML + WinHTTP 的方案。
提示:部署时把
mssoap30.dll和程序放同一目录并随包注册是常见做法,但要注意注册需要管理员权限,静默安装脚本里得留错误分支。
5. 一个更快的验证手段:先手写报文跑通,再回来写代码
排查 SOAP 问题时,最省时间的做法不是加日志,是绕过代码,用最朴素的方式发一次请求。只要能拼出一段服务端认的 XML,剩下的工作就是让代码生成一模一样的字节——问题范围立刻从“整个调用链”缩到“序列化这一段”。
先准备一个请求文件send.xml,内容就是代码该生成的样子:
<?xml version="1.0" encoding="UTF-8"?> <SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://sms.example.com/"> <SOAP-ENV:Body> <tns:send> <cmpcode>0166</cmpcode> <phone>13200000000</phone> <content>测试内容</content> </tns:send> </SOAP-ENV:Body> </SOAP-ENV:Envelope>然后用命令行发一次:
curl -i -s -X POST "http://192.0.2.10:8080/cif/services/SMS" \ -H "Content-Type: text/xml; charset=utf-8" \ -H 'SOAPAction: "http://sms.example.com/send"' \ --data-binary @send.xml-i把响应头也打出来,这一步很关键:HTTP 200 加空 Body 和 HTTP 500 加 Fault,是两类完全不同的问题。前者多半是 action 或命名空间没匹配上,服务端没找到方法却没报错;后者通常带faultstring,直接告诉你缺哪个参数。--data-binary而不是-d,是为了防止 curl 把换行吃掉导致报文变形。
手工跑通之后,让代码进入对照环节:在EndMessage之前把SoapConnector->InputStream的内容读出来打印,跟send.xml逐字符比。常见差异就那么几处——信封上少了xmlns声明、StartElement的前缀跟命名空间绑定不一致、WriteString把中文转成了实体、参数节点被重复声明了默认命名空间。这几种在报文层面一眼能看出来,比在代码里逐行猜快十倍。
再进一步,把send.xml按服务端要求改到能返回正确结果为止,然后把这份“标准报文”固化成回归用例。以后改客户端代码,先跑一遍手工请求确认服务端没变,再跑代码确认输出一致。两边都过,问题一定在业务层而不是协议层。
我自己的习惯是:任何一个新的 SOAP 接口,先用 curl 打通一次,把请求和响应各存一份到项目目录下,命名为接口名_req.xml/接口名_resp.xml,代码里遇到解析异常就拿这两份对照。这个习惯是被一次线上问题逼出来的——当时客户端返回空串,查了两天才发现是对方运维在网关上改了命名空间前缀,报文本身合法,只是我们的 Reader 按旧前缀取节点。从那以后,每次接新接口我都强制先手工跑一遍报文再动手写代码,省下的时间远比花掉的多。
希望帮到你。
本文还有配套的精品资源,点击获取