ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot调用WebService接口全攻略:手拼SOAP XML与WSDL解析实战

SpringBoot调用WebService接口全攻略:手拼SOAP XML与WSDL解析实战

这段时间我一直在跟一个物流轨迹查询WebService接口打交道,对方只给了一个服务地址和一个?wsdl链接。项目是 SpringBoot,团队里没人写过 SOAP 风格的客户端,网上一搜答案五花八门,有的让你上 CXF,有的让你装插件生成代码,看完反而更懵。想明白之后其实很简单:WebService 接口的通信本质就是 HTTP POST 一个 SOAP XML,只要把信封结构理清楚,用什么 HTTP 库都能调通。这篇文章就从 WSDL 分析、方案选型、两种实现方式到常见坑位完整过一遍,给同样在 SpringBoot 项目里对接 WebService 的同学一份能直接上手的参考。

1. 先读懂WSDL再动手:调用前必做的三件事

1.1 WebService接口到底长什么样

很多同学第一次接触 WebService 会下意识问:这不就是一个 HTTP 接口吗,为什么不能直接 RestTemplate POST?原因是 SOAP 协议在普通 HTTP 报文外面又套了一层 XML 信封(Envelope)。服务端读取的不仅是 HTTP 头,还要解析 XML Body 里哪个命名空间、哪个方法名、各个参数放在哪个节点。这层解析逻辑由服务端框架完成,客户端要做的就是把请求组织成它认识的样子。

一个典型的 SOAP 请求长这样:

<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Header/> <soapenv:Body> <tra:queryTrack xmlns:tra="http://www.logistics.com/schema/track"> <tra:trackingNo>SF1234567890</tra:trackingNo> <tra:companyCode>SF</tra:companyCode> </tra:queryTrack> </soapenv:Body> </soapenv:Envelope>

Envelope是固定的外层包装,Header通常放认证信息或扩展头,Body里是真正的业务请求。服务端收到后会从 Body 中提取命名空间和方法名,找到对应处理逻辑,再返回同样格式的 SOAP 响应。只要明白这个链路,后面所有方案都是围绕“如何生成这个信封、如何发送、如何解析返回信封”展开。

1.2 从WSDL里提取调用的关键信息

WSDL 是服务端对外发布的“说明书”,里面包含了调用一个 WebService 需要的全部信息。以我这次对接的物流查询接口为例,简化后的 WSDL 核心结构是这样的:

<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://www.logistics.com/schema/track" targetNamespace="http://www.logistics.com/schema/track"> <wsdl:types> <xsd:schema targetNamespace="http://www.logistics.com/schema/track"> <xsd:element name="queryTrack"> <xsd:complexType> <xsd:sequence> <xsd:element name="trackingNo" type="xsd:string"/> <xsd:element name="companyCode" type="xsd:string"/> </xsd:sequence> </xsd:complexType> </xsd:element> <xsd:element name="queryTrackResponse"> <xsd:complexType> <xsd:sequence> <xsd:element name="trackResult" type="tns:TrackResult"/> </xsd:sequence> </xsd:complexType> </xsd:element> </xsd:schema> </wsdl:types> <wsdl:message name="queryTrackRequest"> <wsdl:part name="parameters" element="tns:queryTrack"/> </wsdl:message> <wsdl:message name="queryTrackResponse"> <wsdl:part name="parameters" element="tns:queryTrackResponse"/> </wsdl:message> <wsdl:portType name="TrackServicePort"> <wsdl:operation name="queryTrack"> <wsdl:input message="tns:queryTrackRequest"/> <wsdl:output message="tns:queryTrackResponse"/> </wsdl:operation> </wsdl:portType> <wsdl:binding name="TrackServiceBinding" type="tns:TrackServicePort"> <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/> <wsdl:operation name="queryTrack"> <soap:operation soapAction="http://www.logistics.com/schema/track/queryTrack"/> <wsdl:input> <soap:body use="literal"/> </wsdl:input> <wsdl:output> <soap:body use="literal"/> </wsdl:output> </wsdl:operation> </wsdl:binding> <wsdl:service name="TrackService"> <wsdl:port name="TrackServicePort" binding="tns:TrackServiceBinding"> <soap:address location="https://api.example.com/logistics/trackService"/> </wsdl:port> </wsdl:service> </wsdl:definitions>

看不懂每个标签没关系,实际调用时只需要从这张表里提取几个关键配置:

关键配置在WSDL中的位置构建请求时怎么用
调用地址 endpoint<service>→<port>→<soap:address location="...">HTTP 请求的 URL
业务命名空间<definitions>的targetNamespaceSOAP Body 中业务元素的命名空间
方法名和入参结构<portType>→<operation>以及<types>里的 SchemaBody 内的 XML 结构和子节点顺序
SOAPAction<binding>→<operation>→<soap:operation soapAction="...">HTTP 请求头里的 SOAPAction 字段

拿到这四样东西,手工拼 XML 的条件就齐了。很多文章跳过这一步直接给代码,实际项目里最容易翻车的地方恰恰是这里:参数名看错一个大小写、命名空间少写一层,服务端就直接给你返回 Fault。

1.3 SOAP 1.1和SOAP 1.2的HTTP头差异

调用前还要确认对方用的是 SOAP 1.1 还是 1.2。区别最直接体现在 HTTP 头的 Content-Type 和 SOAPAction 上,详见下表:

项目SOAP 1.1SOAP 1.2
Envelope命名空间http://schemas.xmlsoap.org/soap/envelope/http://www.w3.org/2003/05/soap-envelope
Content-Typetext/xml; charset=UTF-8application/soap+xml; charset=UTF-8
SOAPAction头通常需要,可为空字符串一般不需要,可在 Content-Type 中加action参数
WSDL里binding命名空间http://schemas.xmlsoap.org/wsdl/soap/http://schemas.xmlsoap.org/wsdl/soap12/

我在这块吃过不少亏。直观感受是 SOAP 1.1 的接口特别执拗,Header 里那个 SOAPAction 要么不填、要么填错,服务端就会报Invalid SOAPAction之类的错误。SOAP 1.2 则宽松得多,某些实现根本不理 SOAPAction,只看 Body 内容。所以第一步永远是打开 WSDL 看 binding 里引用的 soap 命名空间,确定版本之后再写客户端,不要凭感觉猜。

2. 方案选型:四种主流调用方式怎么选

2.1 四种方案速览与对比

SpringBoot 项目里调用 WebService,常见思路就这么几种。我把它们的核心特性和适用场景列出来:

方案核心依赖上手难度灵活性适用场景
JAX-WS 代理JDK自带(高版本需额外模块)低中等WSDL稳定、接口数量多
HttpClient 手拼XML基本无中等高接口少、WSDL可能变动、想完全掌控报文
Spring WebServiceTemplatespring-ws + JAXB等中等中等项目已深度使用 Spring,希望对象化调用
Apache CXFcxf-rt 系列依赖偏高高既需要发布WebService也需要消费大量SOAP接口

每个方案都有它的使用场景。JAX-WS 最适合“WSDL 非常稳定、接口又多”的传统对接,生成代码后调用起来像调本地方法一样直观。HttpClient 手拼 XML 则是我的保底方案,因为它不依赖任何框架,请求和响应报文完全可见,排查问题最快。Spring WebServiceTemplate 适合已经有一套 JAXB 模型、统一走 XML 序列化配置的场景。CXF 功能确实全,但如果只是为了调一两个第三方接口,引入一大坨 CXF 依赖有点杀鸡用牛刀。

2.2 我的选型判断逻辑

我一般按三个问题来决策。第一,这个 WebService 接口会不会经常变?如果对方还在联调期,WSDL 三天两头改,那我不建议用 JAX-WS 生成代码,因为 WSDL 一变,生成的客户端代码就全得重新生成,很痛苦。这时候手拼 XML 反而稳,改一两个节点就行。第二,要调的 SOAP 方法多不多?一次对接只有两三个 method,手写信封成本其实很低。如果一口气要调十几个接口方法,那还是老老实实用 JAX-WS 或 CXF,让工具帮你生成那一大堆请求响应对象。第三,团队本身的 HTTP 客户端基础是什么?如果项目里已经普遍使用 RestTemplate 或 OkHttp,那直接沿用现有 HTTP 客户端手拼 XML 的侵入性最小。

我这次最后选了 HttpClient 手拼 XML 为主力方案。原因很简单:对方是家物流公司,接口文档比较老旧,WSDL 里甚至有一段和实际报文不一致,生成代码方式根本走不通。手拼 XML 让我能把每个字段都看得清清楚楚,出问题直接抓包对比,效率高得多。

3. 手把手实现:HttpClient拼XML调用快递查询服务

3.1 准备一份模拟服务契约

为了完整演示整个过程,我用上面那份 WSDL 对应的快递查询服务作为例子。关键信息如下:

  • 服务地址:https://api.example.com/logistics/trackService
  • 命名空间:http://www.logistics.com/schema/track
  • 方法名:queryTrack
  • 入参:trackingNo(运单号)、companyCode(快递公司编码)
  • SOAPAction:http://www.logistics.com/schema/track/queryTrack

我在 SpringBoot 里建了一个配置类,把这些常量统一放进去,避免散落在代码各个地方。这样测试环境和生产环境切换只需要改配置:

webservice: track: endpoint: https://api.example.com/logistics/trackService namespace: http://www.logistics.com/schema/track soap-action: http://www.logistics.com/schema/track/queryTrack connect-timeout-ms: 5000 read-timeout-ms: 10000

3.2 组装SOAP请求并发送:JDK HttpClient版

先看组装 XML 的方法。构造信封的时候最怕字符串拼接里漏了转义,参数一旦包含&、<这些特殊字符,服务端轻则解析失败,重则直接被安全组件拦截。所以我在拼业务参数前先做一层 XML 转义:

private String escapeXml(String value) { if (value == null) { return ""; } return value.replace("&", "&amp;") .replace("<", "&lt;") .replace(">", "&gt;") .replace("\"", "&quot;") .replace("'", "&apos;"); } private String buildQueryTrackSoap(String trackingNo, String companyCode) { String safeTrackingNo = escapeXml(trackingNo); String safeCompanyCode = escapeXml(companyCode); StringBuilder sb = new StringBuilder(); sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); sb.append("<soapenv:Envelope "); sb.append("xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" "); sb.append("xmlns:tra=\"").append(NAMESPACE).append("\">"); sb.append("<soapenv:Header/>"); sb.append("<soapenv:Body>"); sb.append("<tra:queryTrack>"); sb.append("<tra:trackingNo>").append(safeTrackingNo).append("</tra:trackingNo>"); sb.append("<tra:companyCode>").append(safeCompanyCode).append("</tra:companyCode>"); sb.append("</tra:queryTrack>"); sb.append("</soapenv:Body>"); sb.append("</soapenv:Envelope>"); return sb.toString(); }

这里有个小细节容易踩:escapeXml里&的替换必须放在第一个。如果先替换<再替换&,原本已经转义好的&lt;会被二次转义成&amp;lt;,服务端解出来就是错的。

接下来发送请求。我用的 JDK 自带 HttpClient,Java 11 及以上的 SpringBoot 项目都能直接用,不用加额外依赖:

public TrackResult queryTrack(String trackingNo, String companyCode) { String soapBody = buildQueryTrackSoap(trackingNo, companyCode); HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT)) .header("Content-Type", "text/xml; charset=UTF-8") .header("SOAPAction", SOAP_ACTION) .timeout(Duration.ofSeconds(10)) .POST(BodyPublishers.ofString(soapBody, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); String responseXml = response.body(); return parseTrackResult(responseXml); }

如果项目还在 Java 8,把发送部分换成 HttpURLConnection 就行,XML 组装逻辑完全不变。Java 8 的写法大概是:

HttpURLConnection conn = (HttpURLConnection) new URL(ENDPOINT).openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "text/xml; charset=UTF-8"); conn.setRequestProperty("SOAPAction", SOAP_ACTION); conn.setDoOutput(true); try (OutputStream os = conn.getOutputStream()) { os.write(soapBody.getBytes(StandardCharsets.UTF_8)); os.flush(); } int status = conn.getResponseCode(); InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream(); String responseXml = new String(readAllBytes(is), StandardCharsets.UTF_8);

这里要强调一点:很多 SOAP 服务即使业务调用失败,HTTP 状态码依然是 200,错误信息放在 SOAP Fault 里。所以不能只用状态码判断成功失败,后面专门讲 Fault 处理。

3.3 解析响应XML,抽取业务数据

服务端返回的 SOAP 响应通常是这样的:

<?xml version="1.0" encoding="UTF-8"?> <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tra="http://www.logistics.com/schema/track"> <soapenv:Body> <tra:queryTrackResponse> <tra:trackResult> <tra:state>DELIVERED</tra:state> <tra:description>已签收,签收人:前台</tra:description> <tra:updateTime>2025-01-15 14:22:00</tra:updateTime> </tra:trackResult> </tra:queryTrackResponse> </soapenv:Body> </soapenv:Envelope>

解析方式我推荐先用 DOM 加载,再用getElementsByTagNameNS直接取节点。这个方法要传完整的命名空间 URI,而不是前缀,因此不会出现前缀对不上的问题:

private TrackResult parseTrackResult(String responseXml) { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); try { Document doc = factory.newDocumentBuilder() .parse(new ByteArrayInputStream(responseXml.getBytes(StandardCharsets.UTF_8))); String namespace = "http://www.logistics.com/schema/track"; TrackResult result = new TrackResult(); result.setState(getElementText(doc, namespace, "state")); result.setDescription(getElementText(doc, namespace, "description")); result.setUpdateTime(getElementText(doc, namespace, "updateTime")); return result; } catch (Exception e) { throw new RuntimeException("解析SOAP响应失败", e); } } private String getElementText(Document doc, String namespace, String localName) { NodeList nodes = doc.getElementsByTagNameNS(namespace, localName); if (nodes.getLength() == 0) { return null; } return nodes.item(0).getTextContent(); }

解析响应时,命名空间必须和 WSDL 里的targetNamespace完全一致。有些响应里前缀是ns1、ns2,但 URI 始终是那个固定值。用getElementsByTagNameNS只认 URI 不认前缀,所以前缀随便变都不影响解析,这也是我优先用它的原因。

4. 优雅方案:用JAX-WS代理类调用

4.1 什么时候值得用JAX-WS

如果你面对的 WSDL 非常稳定、接口方法又多,那我建议用 JAX-WS 生成客户端。这种方式最大的好处是省心:请求对象、响应对象、类型转换全自动生成,调方法就像调用本地对象一样。不需要自己拼 XML,也不需要手写 DOM 解析。

但代价也很明显:生成的代码量很大,一个接口动辄几十个类,而且和 WSDL 强绑定。要是对方改一次字段名、改一个顺序,这些类就得重新生成一遍。另外在高版本 JDK 里,原来 JDK 自带的wsimport命令不再随 JDK 一起发布,需要额外引入模块,这本身就是一笔维护成本。所以我的建议是:接口数量在五个以上、WSDL 一年到头基本不动的老系统,用 JAX-WS 才划算。

4.2 wsimport生成客户端与Service动态调用

使用 JAX-WS 的第一步是用工具读取 WSDL 生成 Java 代码。Java 8 环境里命令直接可用:

wsimport -keep -s src/main/java -p com.example.trackclient \ https://api.example.com/logistics/trackService?wsdl

-s是指定源码目录,-p是生成的包名。执行完会得到若干类,其中会有TrackService(服务入口)、TrackServicePort(端口代理接口)、以及请求响应相关对象。随后在 SpringBoot 里注入并使用:

@Service public class TrackJaxWsClient { public String queryTrack(String trackingNo, String companyCode) throws Exception { URL wsdlUrl = new URL("https://api.example.com/logistics/trackService?wsdl"); QName serviceName = new QName( "http://www.logistics.com/schema/track", "TrackService"); Service service = Service.create(wsdlUrl, serviceName); TrackServicePort port = service.getPort(TrackServicePort.class); // 注意:生成代码的具体方法签名取决于WSDL, // 这里以QueryTrackResponse对象为例 QueryTrackResponse response = port.queryTrack(trackingNo, companyCode); return response.getTrackResult().getState(); } }

这套流程写起来很顺,但实际维护中有一个容易被忽略的问题:Service.create每次都会去远程拉一次 WSDL。如果启动时网络抖动,整个 Bean 初始化都可能失败。稳妥做法是把 WSDL 文件下载到本地资源目录,通过getClass().getClassLoader().getResource("wsdl/trackService.wsdl")拿本地 URL 来创建 Service,避免每次启动依赖远端服务可用性。

4.3 高版本JDK的目录与替代建议

Java 9 之后模块化割裂了 JAX-WS,Java 11 开始不再提供wsimport命令。想继续走生成代码这条路,有两个常规选择。

一个是引入独立的 JAX-WS 实现依赖,比如jakarta.xml.ws对应的 API 和jaxws-ri,然后自己找工具或者用 Maven 插件在构建阶段执行 WSDL 到 Java 的转换。这个方案比较繁琐,依赖版本还得跟 JDK 匹配。

另一个是直接改用 CXF 的wsdl2java工具生成客户端。虽然是 CXF 体系,但生成代码后实际运行成本可控,灵活性也好。不过 CXF 依赖较多,如果不希望项目里多出这么一大坨框架,那就还是回到手拼 XML 的方案。说实话,我在新项目里已经很少用 JAX-WS 了,大多时候手拼 XML 加上单元测试,反而比维护一整套生成代码更舒服。

5. 返回结构复杂时:XPath解析响应与SOAP Fault处理

5.1 用XPath按命名空间取值

如果 SOAP 响应里的业务结构套了很多层,用getElementsByTagNameNS一个个取节点会写得很累。这时候可以换成 XPath,一次表达式拿最终值:

private String evalXPath(Document doc, String expression) throws Exception { XPath xPath = XPathFactory.newInstance().newXPath(); xPath.setNamespaceContext(new NamespaceContext() { @Override public String getNamespaceURI(String prefix) { if ("soapenv".equals(prefix)) { return "http://schemas.xmlsoap.org/soap/envelope/"; } if ("tra".equals(prefix)) { return "http://www.logistics.com/schema/track"; } return XMLConstants.NULL_NS_URI; } @Override public String getPrefix(String namespaceURI) { if ("http://schemas.xmlsoap.org/soap/envelope/".equals(namespaceURI)) { return "soapenv"; } if ("http://www.logistics.com/schema/track".equals(namespaceURI)) { return "tra"; } return null; } @Override public Iterator<String> getPrefixes(String namespaceURI) { List<String> prefixes = new ArrayList<>(); if ("http://schemas.xmlsoap.org/soap/envelope/".equals(namespaceURI)) { prefixes.add("soapenv"); } if ("http://www.logistics.com/schema/track".equals(namespaceURI)) { prefixes.add("tra"); } return prefixes.iterator(); } }); return xPath.evaluate(expression, doc); }

用的时候直接写:

String state = evalXPath(doc, "/soapenv:Envelope/soapenv:Body/tra:queryTrackResponse/tra:trackResult/tra:state");

这里有个非常关键的认知:XPath 表达式里的前缀只是个符号,真正决定匹配的是NamespaceContext里前缀映射到的 URI。哪怕返回的 XML 里实际前缀是ns1、tns,只要映射到同一个 URI,表达式照样能匹配。但如果忘了setNamespaceContext,XPath 会把无前缀节点当成空命名空间,你写的路径永远匹配不到。这个坑排查起来极其痛苦,我建议一上来就把DocumentBuilderFactory的setNamespaceAware(true)开好。

5.2 SOAP Fault识别与业务异常转换

SOAP 协议里,业务异常不是用 HTTP 状态码表达的,而是返回专门的 Fault 节点:

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Body> <soapenv:Fault> <faultcode>soapenv:Client</faultcode> <faultstring>Invalid tracking number</faultstring> <detail> <tra:errorCode>TRACK_NOT_FOUND</tra:errorCode> </detail> </soapenv:Fault> </soapenv:Body> </soapenv:Envelope>

所以在客户端拿到响应后,先检测里面有没有<soapenv:Fault>或者<soap:Fault>节点,有就直接抽faultstring和detail里的错误码,转成业务异常抛出去。否则请求结果明明是失败的,上层还当成功处理,等排查问题的时候才发现数据全不对。

我用 JDK HttpClient 时,HTTP 500 响应不会自动抛异常,send方法照样返回HttpResponse对象,body 就是 Fault XML。如果项目里用的是 RestTemplate,它会在非 2xx 状态码时抛异常,此时要从HttpClientErrorException.getResponseBodyAsString()里取出 XML 再解析 Fault。两种框架表现完全不同,写统一封装时一定得小心。

5.3 多层嵌套响应:用JAXB解组是更好的归宿

如果响应对象层级很深,手写 DOM 或 XPath 都算不上优雅,不如直接用 JAXB 把 XML 解组成 Java 对象。前提是 WSDL 的 XSD 里给出了完整的复杂类型定义,或者你已经通过工具生成了对应的模型类。做法是把 SOAP 响应里的 Body 子节点单独抽出来,作为根节点传给Unmarshaller,然后得到对象。这个方案的好处是类型安全,后续代码完全面向对象。坏处是模型类要跟着 XSD 走,如果 XSD 结构太乱,生成出来的对象也很难看。我自己一般只会在解析逻辑特别复杂时才上 JAXB,简单响应用 XPath 就够了。

6. 实际项目中避不开的坑

6.1 SOAPAction的三种形态

SOAPAction 这个 Header 在现实里大概有三种形态。第一,WSDL 的 binding 里明确写了一个具体 URI,比如http://www.logistics.com/schema/track/queryTrack,那就原样填上去。第二,binding 里写的是空字符串,表示服务端根本不看这个字段,填一个空字符串就行。第三,部分历史遗留系统要求 SOAPAction 的值必须用引号包起来,比如"http://www.logistics.com/schema/track/queryTrack",如果没加引号,服务端会直接报错。

最烦的其实是第三种,它没有规律可循,而且错误提示往往还不明确。我的排查顺序是:先看 SoapUI 实际发送的请求头长什么样,再照着改代码。SoapUI 里能看到完整的 HTTP 请求预览,SOAPAction 带不带引号一目了然。

6.2 超时、连接池与HTTPS证书

WebService 接口通常比普通 HTTP API 慢,尤其一些老系统内部还要查库、调其他系统,超时时间给太短很容易误报故障。我给连接超时和读取超时设置不同的值:连接超时一般 5 秒足够,读取超时至少给 10 秒,批量查询或者下游链路复杂的给到 30 秒。在 SpringBoot 里推荐把这两个值放进配置,和 endpoint 放一起管理。

另一个常见问题是 HTTPS 证书。企业内部 WebService 大量使用自签名证书,代码里一请求就报PKIX path building failed。排查时先用浏览器打开服务地址确认证书链是否可信。如果是自签名,联调阶段有两个常规处理:拿到对方证书后用 keytool 导入本地 JVM 信任库;或者写一个测试专用的 TrustManager 临时放开校验。但测试用的 TrustManager 绝对不要带上生产,这是底线。

6.3 中文与XML特殊字符处理

中文乱码是最容易在联调时冒出来的问题。解决办法很朴素:请求发送时用 UTF-8 编码,解析响应时也用 UTF-8,两边都显式指定,不依赖系统的默认编码。JDK HttpClient 里用BodyPublishers.ofString(soapBody, StandardCharsets.UTF_8)发送,响应用BodyHandlers.ofString(StandardCharsets.UTF_8)接收,就不会有乱码。

XML 特殊字符主要出现在拼接请求的时候。运单号这类业务字段虽然不太可能带<或&,但备注、地址这类自由文本字段就说不准了。我习惯在所有业务参数进入 XML 前统一过一遍转义方法。如果参数里带了&却没转义,生成的 XML 直接就是不合法的,服务端解析会断在最前面,错误信息根本看不出是哪个参数导致的。

6.4 无参方法、空节点与nil值

WebService 里经常有getVersion()、getTime()这类无参方法。构造请求时,Body 里依然要放一个对应方法的空节点,比如:

<soapenv:Body> <tra:getVersion/> </soapenv:Body>

有些旧实现甚至要求空节点必须带自闭合标记,否则会报方法不存在。这个细节不难处理,但容易忽略。

解析响应时还要提防空节点。比如description节点内容为空,返回的是<tra:description/>,如果用getTextContent()拿到的是空字符串,而不是 null。业务判断时必须把空字符串和 null 都考虑进去。另外有些服务对空值会输出<tra:description xsi:nil="true"/>,这种节点如果没解析到,查起来会怀疑是不是字段名写错,实际上只是空值表达方式不同。遇到这种情况,最有效的办法是把完整响应 XML 打到日志里再核对。

7. 调试利器:SoapUI、curl与请求日志

7.1 先用SoapUI把契约跑通

每次对接新的 WebService,我都建议先别急着写代码,打开 SoapUI,把 WSDL 地址粘进去新建项目。SoapUI 会自动列出所有方法和入参结构,自动生成一个标准的 SOAP 信封,你只需要填真实参数点发送,就能立刻看到服务端原始响应。这一步能帮你确认三件事:接口通不通、SOAPAction 对不对、响应里实际返回的字段和文档是否一致。尤其当对方文档和真实接口不一致时,SoapUI 的响应就是你写客户端的最可靠依据。

7.2 curl快速验证与请求文件管理

联调阶段我习惯把一次真实的 SOAP 请求保存成.xml文件,用 curl 发。好处是能随时对照、随时改动,而且无论谁接手项目,看一眼文件就知道报文长什么样。示例:

curl -s -i -X POST \ -H "Content-Type: text/xml; charset=UTF-8" \ -H 'SOAPAction: "http://www.logistics.com/schema/track/queryTrack"' \ --data-binary @request.xml \ https://api.example.com/logistics/trackService

用--data-binary而不是--data,可以避免 curl 对文件内容做表单编码,把 XML 原样发出去。如果怀疑是网络层问题,还能加-v看完整请求头和响应头。这个命令留在项目文档里,比贴一堆代码更直观。

7.3 SpringBoot里给SOAP请求加日志

调用 WebService 最怕黑盒。我在自定义的客户端类里,在发送前和接收后分别打一条日志,输出完整请求 XML 和响应 XML。级别用 DEBUG,平时不刷屏,排查问题时打开 debug 就能复现整个过程。

log.debug("SOAP request: {}", soapBody); log.debug("SOAP response: {}", responseXml);

如果是用 JAX-WS 或者 CXF,全局日志开关通常靠框架自带的 handler 或拦截器实现。但坦白讲,我在实际项目里接手过好几套 JAX-WS 生成代码,里面根本没有打印 XML 的地方,出问题只能抓包。这也是我后来越来越倾向手拼 XML 方案的原因之一,至少报文在自己手里,日志想怎么打就怎么打。

最后再分享一个小习惯:我把 WSDL 文件下载到项目src/main/resources/wsdl目录里存着,同时把 SoapUI 跑通过的一份请求样例、一份响应样例一起放在docs目录。这样哪怕过了半年、文档找不到了、WSDL 地址失效了,只要翻项目里的样例文件,照样能快速弄清这个接口长什么样。对接 WebService 的本质就是把通信细节显式化,谁从一开始把报文摊在明面上,谁后面调试就少掉头发。

RELATED READING

延伸阅读

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