前一阵在群里看到有人问:SpringBoot 项目里怎么调 WebService 接口?这个问题看上去简单,真上手做过的都知道,坑一点都不少。SpringBoot 默认提供的 RestTemplate、OpenFeign 那套东西对 WebService 完全无效,因为 WebService 用的是 SOAP 协议、XML 报文,走的是另一套消息规范。这篇东西不是抄官方文档,是我在实际项目里对接过多个外部 SOAP 服务之后的完整经验总结。内容涵盖 wsimport 静态生成代码、WebServiceTemplate 动态调用两种主流方案,以及 JDK 版本、JAXB、SOAPAction、超时、认证这些绕不开的细节。适合正在做 SpringBoot 服务对接、或者刚接手老系统集成的开发者参考。
1. 动手前请先搞清三件事
1.1 WebService 不是 REST,调用姿势不一样
很多人拿到接口文档后,第一反应是拿 RestTemplate 去 POST 一个地址,结果发现怎么调都不对。根本原因是 WebService 和 REST 虽然都基于 HTTP,但消息结构完全不同。
WebService 从狭义上讲主要指 SOAP WebService,它使用 XML 作为消息载体,报文外面套了一层 SOAP Envelope,里面包含 SOAP Header 和 SOAP Body。服务端通过 WSDL 文件描述接口、入参、出参、绑定地址等信息。客户端拿到 WSDL 之后,理论上可以自动生成一套 Java 调用代码,不需要手工拼 XML,也不应该手工去拼 XML。
REST 讲究资源语义、JSON 为主;SOAP 讲究动作调用、XML 为主,经常还会要求 SOAPAction 头信息。这两者的思维模式、实现方式、调试手段都不同。所以第一步不是写代码,而是确认对面到底是不是 SOAP WebService,拿到 WSDL 地址再说。
1.2 从 WSDL 里提取关键信息
WSDL 对新手来说像天书,其实只需要关注几个关键点。一个标准 WSDL 里有这几个主要部分:types(定义数据类型的 XML Schema)、message(定义请求和响应消息)、portType(定义操作名称)、binding(定义协议绑定和 SOAPAction)、service(定义服务地址)。
我拿到一个 WSDL 后,通常只关心三件事:
- 服务的命名空间,比如
http://example.com/userws,这个后面生成代码、设置 SOAPAction 都要用到。 - 有哪些操作,每个操作的入参、出参是什么。在
portType里看<wsdl:operation name="GetUser">这种节点就行。 - SOAPAction 的值,在
binding里找<soap:operation soapAction="http://example.com/userws/GetUser"/>。
把这三个信息记下来,对接工作就已经完成一大半。剩下的无非是选一种方式把报文发出去。
1.3 三种调用方案怎么选
Java 调 WebService 没有银弹,主流有三条路:
- wsimport 生成静态客户端代码。这是 JDK 自带工具,基于 JAX-WS 规范生成一组 Java 类,拿来直接用。优点是对接方固定时最稳定,缺点是 WSDL 一变就得重新生成。
- Spring WebServiceTemplate 动态调用。Spring Web Services 提供的模板类,配合 JAXB 做对象和 XML 互转,运行时指定服务地址和请求对象。优点是灵活,同一个模板可以调不同服务,缺点是 JAXB 上下文、SOAPAction、认证这些细节都要自己配。
- Apache CXF 框架。功能最全,支持 WS-Security、WS-Addressing 等一堆扩展标准,但为了调一两个接口引入整套框架,重了。
我的实际选择标准是:接口少(比如少于 5 个)、结构长期稳定的,用 wsimport 最省事;接口多、对接方环境复杂、需要动态切换地址的,用 WebServiceTemplate。CXF 除非对方明确要求 WS-Security 令牌这类高级功能,否则没必要上。
2. 环境准备:依赖、JDK 与常见暗坑
2.1 最小 Maven 依赖
如果是用 wsimport 生成代码的那套方案,技术上最干净,理论上只需要spring-boot-starter-web-services。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web-services</artifactId> </dependency>这个 starter 会带进 Spring WS 的核心库和 SAAJ 实现,够用了。如果你选择 WebServiceTemplate 方案,还需要 JAXB 相关的库,以及后面的 HTTP 客户端超时配置依赖。
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.2</version> </dependency>需要说明一点,spring-boot-starter-web-services并不直接包含 javax.xml.ws 的相关实现。如果 wsimport 生成的代码里用的是 JAX-WS 的Service、Dispatch这些类,在 JDK8 里没问题,因为 JDK8 自带了 JAX-WS 模块;但换了 JDK11 以后就麻烦了,下面专门说。
2.2 JDK11 之后 wsimport 和 JAXB 都“丢”了
这是很多人升级 JDK 之后踩到的第一坑。JDK8 里wsimport是自带命令,JDK11 开始把 JAX-WS、JAXB 模块从 JDK 里移除了。表现为:
- 命令行执行
wsimport提示找不到命令。 - 运行时报
java.lang.ClassNotFoundException: javax.xml.bind.JAXBException或javax.xml.ws.WebServiceException。
解决办法有几个。最省事的做法是回到 JDK8 环境用 wsimport 生成代码,然后把代码带进新工程。命令行工具是一次性的,生成完代码就不需要了,这是很多老项目实际操作上的做法。
如果想在 JDK11/17 环境下用 wsimport,可以从老的 JDK8 安装目录里把wsimport相关 jar 抄出来用,也可以改用 CXF 的wsdl2java命令来做。wsdl2java生成的是 CXF 风格的客户端,代码量和依赖都更重,我不太推荐。
还有一种思路是完全绕开 wsimport,用纯 JAXB 加 WebServiceTemplate 自己拼请求对象。这也是我目前在新项目里的主推方式,后面的方案二会展开。
2.3 版本兼容组合
结合我踩过的坑,比较稳的组合是:
| 场景 | JDK | Spring Boot | 备注 |
|---|---|---|---|
| 老项目维护 | 8 | 2.3 ~ 2.7 | wsimport 可用,JAXB 不需要额外引入 |
| 新项目对接 | 11/17 | 2.5 ~ 2.7 | 需引入 JAXB API 和 runtime |
| 新项目对接 | 17 | 3.x | 注意 Spring Boot 3 基于 Jakarta EE,javax 包名全部要换成 jakarta |
如果用到 Spring Boot 3,上面的javax.xml.bind就得替换成jakarta.xml.bind,wsimport 生成的代码大概率也不能直接用,需要重新理解整套兼容关系。这也是我还守着 Spring Boot 2.7 的原因之一,稳定,资料多,坑基本被前人踩平了。
3. 方案一:wsimport 静态代码,粘过来就能用
3.1 wsimport 命令实战
假设对接方给了 WSDL 地址http://example.com/userws?wsdl,目标包名定为com.example.client.userws。我在本地是这样生成代码的:
mkdir -p src/main/java wsimport -keep -verbose \ -p com.example.client.userws \ -d src/main/java \ -s src/main/java \ http://example.com/userws?wsdl几个参数的含义说一下:
-keep:保留生成的 Java 源码,不只是编译后的 class。-p:指定包名,避免默认包名和别人的混在一起。-d:class 文件输出目录。-s:Java 源码输出目录。-verbose:打印生成日志,方便排错。
如果 WSDL 里引用了外部的 XSD,wsimport 默认会去线上拉,报 SSL 证书错误时加这个 JVM 参数:
wsimport -J-Djavax.xml.accessExternalDTD=file -J-Djavax.net.ssl.trustStore=你的证书库 ...生成完成后,你会得到这些文件:
UserService.java:服务的入口类,对应 WSDL 的service元素。UserServiceSoap.java:Port 接口,真正干活的代理接口,对应 WSDL 的portType。GetUser.java、GetUserResponse.java:请求、响应模型。ObjectFactory.java:JAXB 的工厂类。
注意:有些服务会同时暴露两个 Port,比如
UserServiceSoap和UserServiceSoap12,分别对应 SOAP 1.1 和 SOAP 1.2。对接前确认对端用哪种协议,选错会出现 HTTP 404 或者解析报文失败的诡异问题。
3.2 生成代码如何接入 SpringBoot 容器
生成代码之后,别把调用逻辑散落在各个业务类里。我习惯把服务入口注册成一个单例 Bean,业务层只管注入调用。
@Configuration public class UserWsClientConfig { @Value("${userws.url}") private String wsUrl; @Bean public UserServiceSoap userServiceSoap() throws MalformedURLException { UserService service = new UserService(new URL(wsUrl)); return service.getUserServiceSoap(); } }必须要提一个细节:UserService在生成代码里是个类,每次new UserService(new URL(wsUrl))都会重新解析 WSDL、初始化 JAXB 上下文,开销不小。把它做成单例 Bean,应用启动时初始化一次,后面调用都是轻量操作。
调用代码:
@Service public class UserServiceClient { private final UserServiceSoap userServiceSoap; public UserServiceClient(UserServiceSoap userServiceSoap) { this.userServiceSoap = userServiceSoap; } public GetUserResponse getUser(String userId) { GetUser request = new GetUser(); request.setUserId(userId); return userServiceSoap.getUser(request); } }有些生成的代理类不是线程安全的,尤其是内部持有 web service 调用状态的那种,使用时要关注一下。不过 JAX-WS 标准生成的代理通常建议复用,这里还是以实际测试为准。
3.3 静态方案的优缺点
静态方案的好处是代码生成后是确定的,IDE 里能直接点进方法看参数,编译期就能发现接口变化,排查问题最直观。缺点是 WSDL 一变,整个客户端代码要重新生成,生成后可能覆盖手工改过的部分。我们项目里曾经出现过一个尴尬局面:对端改了一个字段的必填规则,我们没重新生成代码,手工改了一处 Java 文件,结果下一次重新生成时全部还原了。
所以用这个方案时,我会把 WSDL 文件下载到本地工程里做版本管理。哪天对端说“我们更新了接口”,第一步是 diff 本地 WSDL 和远程 WSDL,心里有数再重新生成代码。
4. 方案二:WebServiceTemplate 动态调用,灵活但细节更多
4.1 核心 Bean 配置
Spring Web Services 提供WebServiceTemplate,作用类似 JdbcTemplate,封装了发送 SOAP 请求、接收响应、做 XML 和对象互转的过程。它的核心搭档是Jaxb2Marshaller,负责把 Java 对象序列化成 XML、把响应 XML 反序列化成 Java 对象。
@Configuration public class WebServiceTemplateConfig { @Value("${ws.default-url}") private String defaultUrl; @Bean public Jaxb2Marshaller jaxb2Marshaller() { Jaxb2Marshaller marshaller = new Jaxb2Marshaller(); // 这里填的是请求/响应模型所在的包名,多个包用冒号分隔 marshaller.setContextPath("com.example.schema.user"); return marshaller; } @Bean public WebServiceTemplate webServiceTemplate(Jaxb2Marshaller jaxb2Marshaller) { WebServiceTemplate template = new WebServiceTemplate(); template.setMarshaller(jaxb2Marshaller); template.setUnmarshaller(jaxb2Marshaller); template.setDefaultUri(defaultUrl); return template; } }JAXB 的contextPath必须和请求对象所在包严格对应。请求对象可以是手动写的、带@XmlRootElement注解的 POJO,也可以是用 JAXB 从 XSD 生成的对象。这里的规则是,JAXB 会根据对象上的@XmlType、@XmlElement自动生成命名空间和标签名,你的对象字段如果和对端报文对不上,序列化出来的 XML 就是错的。
4.2 构造请求并发送
假设我们有一个查询用户的接口,命名空间是http://example.com/userws,操作名是GetUser。请求对象如下:
@XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "GetUser", namespace = "http://example.com/userws") public class GetUser { @XmlElement(name = "userId", namespace = "http://example.com/userws") private String userId; public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } }对应响应:
@XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "GetUserResponse", namespace = "http://example.com/userws") public class GetUserResponse { @XmlElement(name = "userName", namespace = "http://example.com/userws") private String userName; }调用时有两个细节必须注意。一是报文根节点名称和命名空间必须与 WSDL 定义一致,二是 SOAPAction。
import org.springframework.ws.soap.client.core.SoapActionCallback; public GetUserResponse getUser(String userId) { GetUser request = new GetUser(); request.setUserId(userId); return (GetUserResponse) webServiceTemplate.marshalSendAndReceive( request, new SoapActionCallback("http://example.com/userws/GetUser") ); }SoapActionCallback会在发送时自动在 HTTP Header 里加上SOAPAction字段。很多服务端如果发现 SOAPAction 缺失或者不匹配,会直接拒绝请求,表现就是 HTTP 500 或者返回一个 SOAP Fault。有经验的对接者会先在 WSDL 的 binding 里把这个值查清楚。
4.3 自定义 SOAP Header 做认证
SOAP 服务的认证方式五花八门,最常见的是 Basic Auth 和 SOAP Header 里塞凭证。Basic Auth 好办,直接在请求工厂里配用户名密码就行。麻烦的是第二种,需要在 SOAP Header 里手动加子节点。
import javax.xml.soap.SOAPElement; import javax.xml.soap.SOAPException; import javax.xml.soap.SOAPHeader; import javax.xml.soap.SOAPMessage; import org.springframework.ws.WebServiceMessageCallback; import org.springframework.ws.soap.saaj.SaajSoapMessage; public class AuthHeaderCallback implements WebServiceMessageCallback { private final String username; private final String password; public AuthHeaderCallback(String username, String password) { this.username = username; this.password = password; } @Override public void doWithMessage(WebServiceMessage message) throws IOException { SaajSoapMessage saajSoapMessage = (SaajSoapMessage) message; try { SOAPMessage saajMessage = saajSoapMessage.getSaajMessage(); SOAPHeader header = saajMessage.getSOAPHeader(); SOAPElement auth = header.addChildElement("AuthHeader", "auth", "http://example.com/auth"); auth.addChildElement("username").setTextContent(username); auth.addChildElement("password").setTextContent(password); } catch (SOAPException e) { throw new RuntimeException("构造SOAP Header失败", e); } } }调用时带上这个 Callback:
webServiceTemplate.marshalSendAndReceive( request, new AuthHeaderCallback("admin", "secret") );这里最容易踩的坑是命名空间。addChildElement三个参数分别是标签名、前缀、命名空间 URI。如果只写标签名,不指定命名空间,某些服务端会因为你没有按规定声明命名空间而拒收。见过的报错是org.springframework.ws.soap.saaj.SaajSoapEnvelopeException,多半就是 Header 里的元素没有正确的命名空间。
4.4 超时和线程安全
WebServiceTemplate 默认用的SimpleClientHttpRequestFactory对超时的支持很弱,很多情况下等于没有超时。外部系统一旦卡住,你的线程池很快会被耗光。我统一用HttpComponentsClientHttpRequestFactory替换。
先说依赖:
<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> </dependency>再改配置:
@Bean public WebServiceTemplate webServiceTemplate(Jaxb2Marshaller jaxb2Marshaller) { WebServiceTemplate template = new WebServiceTemplate(); template.setMarshaller(jaxb2Marshaller); template.setUnmarshaller(jaxb2Marshaller); template.setDefaultUri(defaultUrl); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); requestFactory.setConnectTimeout(5000); requestFactory.setConnectionRequestTimeout(5000); requestFactory.setReadTimeout(30000); template.setRequestFactory(requestFactory); return template; }其中,connectTimeout是建立连接的超时,readTimeout是等待响应的超时,connectionRequestTimeout是从连接池获取连接的超时。这三个参数我建议按实际业务量级控制,外部接口如果平均响应只要几百毫秒,读超时设 10 秒都够;如果涉及跨系统慢查询,最多给 30 秒,时间再长线程就一直挂在远程调用上,对调用方是灾难。
5. 一个完整的调用示例
5.1 定义模拟服务与 Schema
为了说明整套流程,我构造一个最简单的“查询用户姓名”服务。服务 WSDL 的关键定义是:
- 命名空间:
http://example.com/userws - 请求:
<GetUser><userId>1001</userId></GetUser> - 响应:
<GetUserResponse><userName>张三</userName></GetUserResponse> - SOAPAction:
http://example.com/userws/GetUser
这种结构在实际工作中很典型,许多老旧系统的基础查询接口就是这么写的。
5.2 请求/响应模型
手动写两个 JAXB 模型类,放在包com.example.schema.user下。上面 4.2 小节给出的两个类直接可用。这里要强调@XmlRootElement的namespace值必须和 WSDL 的targetNamespace完全一致。大小写、斜杠、结尾有没有斜杠,差一个字符都会出问题。
我遇到过这样的坑:对端命名空间写的是http://example.com/userws/,而我习惯写http://example.com/userws,结果每次反序列化都失败。排查半天,最后拿抓包工具一比对才看到是结尾斜杠的问题。
5.3 配置 WebServiceTemplate
沿用 4.1 的配置类,把defaultUrl指向http://example.com/userws。
application.yml 配置:
ws: default-url: http://example.com/userws如果这个 URL 在测试环境和生产环境不一样,用 Spring 的 profile 机制区分开就行。
5.4 调用代码和验证结果
完整的调用客户端:
@Service public class UserQueryClient { private final WebServiceTemplate webServiceTemplate; public UserQueryClient(WebServiceTemplate webServiceTemplate) { this.webServiceTemplate = webServiceTemplate; } public String queryUserName(String userId) { GetUser request = new GetUser(); request.setUserId(userId); GetUserResponse response = (GetUserResponse) webServiceTemplate.marshalSendAndReceive( request, new SoapActionCallback("http://example.com/userws/GetUser") ); return response.getUserName(); } }如果你在本地搭了 mock 服务,直接调用就能看到效果。这里值得注意的一点是,marshalSendAndReceive的返回对象类型在编译期是Object,必须强转。很多人强转之后报ClassCastException,原因往往是 JAXB 根据响应 XML 推断出的实际对象不是我们预期的类型,而不是代码写错。遇到这种情况,把响应 XML 抓出来和 WSDL 里的 message 定义对一遍,基本能找到原因。
5.5 日志确认报文结构
为了确认实际发出的报文长什么样,我是这样配置日志的:
logging: level: org.springframework.ws.client.MessageTracing: TRACE org.springframework.ws.server.MessageTracing: TRACE开启后,控制台会打印出请求和响应的 XML。此时你能直观看到根节点、命名空间、SOAPAction 是否正确。这个习惯帮我解决过无数次“看起来对但调不通”的问题。
6. 问题速查与排障实录
6.1 六个高频异常对照
把这些年见过的典型问题整理成了一张速查表,遇到问题对着找:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
启动报ClassNotFoundException: javax.xml.bind.JAXBException | JDK11 及以上缺 JAXB 模块 | 引入jaxb-api和jaxb-runtime |
发送报WebServiceException,找不到 WSDL | 初始化时远程 WSDL 访问不了 | 下载 WSDL 到本地 classpath,用代码指定 URL |
| 发送报 HTTP 404 | 服务地址不对,或 SOAP 协议版本选错(1.1 和 1.2) | 核对地址,确认用对 Port |
| 发送报 HTTP 500,返回 SOAP Fault | SOAPAction 缺失、不匹配,或报文必填字段缺失 | 用日志确认 SOAPAction 和报文结构 |
响应反序列化抛ClassCastException | 响应 XML 的命名空间或根节点和预期不符 | 抓包比对 WSDL 的 message 定义 |
| 偶发性超时、线程卡死 | 未配置连接超时/读超时 | 使用HttpComponentsClientHttpRequestFactory配置超时 |
6.2 三步定位法:日志、抓包、SoapUI
遇到调不通的情况,我的排查顺序固定三步。
第一步,开 HTTP 日志。先确认请求有没有发出去、服务端有没有响应、状态码是多少。这一步能过滤掉一半的问题。
第二步,抓包或者用 SoapUI。如果日志只显示框架层的信息,看不到原始 XML,我会用本机代理工具把 HTTP 流量导出来,或者直接复制请求报文用curl模拟一遍。
curl -v \ -H "Content-Type: text/xml; charset=utf-8" \ -H 'SOAPAction: "http://example.com/userws/GetUser"' \ --data-binary @request.xml \ http://example.com/userwsrequest.xml的内容就是一次完整 SOAP 请求:
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:user="http://example.com/userws"> <soapenv:Header/> <soapenv:Body> <user:GetUser> <user:userId>1001</user:userId> </user:GetUser> </soapenv:Body> </soapenv:Envelope>用 curl 能直接看到服务端返回的完整响应,这比在代码里反复猜快得多。
第三步,在 SoapUI 里导入 WSDL,直接调用对端接口。这一步的作用是确认对端服务本身是通的、报文格式是对的。如果 SoapUI 里能调通,问题基本就锁定在 Java 客户端这边;如果 SoapUI 也调不通,那就是对端接口或者本地网络环境的问题,别在代码里白耗时间。
6.3 生产环境稳定性经验
最后说说上线之后容易忽略的几个点,这些是用线上事故换来的经验。
第一,必须把 WebService 调用的超时、重试和熔断纳入监控体系。SOAP 服务很多是老系统,稳定性参差不齐,不能假设它永远可靠。超时参数要按上文设置,重试要谨慎——查询类接口可以重试,下单、转账这类非幂等操作绝对不能盲目重试,否则可能出现重复提交。
第二,关注线程池。如果 WebServiceTemplate 是共享单例,多线程并发调用时,底层的连接池和线程调度很容易成为瓶颈。流量大的接口建议单独配置连接池参数,不要所有接口共用一个默认值。
第三,做好降级方案。某外部系统升级或故障时,调用方要有兜底逻辑,比如返回缓存数据、走备用通道。而不是让请求一直挂到超时,把整个应用拖垮。
第四,把 WSDL 文件连同版本号一起放进工程管理。外部系统更新接口时,用 diff 工具对比 WSDL 变更点,能精准定位对方改了哪些字段、哪些操作,避免盲人摸象。
我个人在实际项目里的体会是,SpringBoot 调 WebService 本身不算复杂,真正花时间的是理解 SOAP 的报文结构、命名空间、SOAPAction 这些约定。只要把 WSDL 吃透,不管用 wsimport 还是 WebServiceTemplate,都能做到心里有底。如果对接的接口数量不多,优先用 wsimport 静态生成,代码简单直观;如果希望运行时灵活切换地址、统一处理认证和超时,WebServiceTemplate 是更合适的底座。最后再提一个小技巧:上线前把关键的请求报文、响应报文打印成独立日志,出差错时这份日志就是定位问题的第一现场,比什么都管用。