如果一个跑了大半年的WebService接口突然在批量任务里集体报错,抛出来的异常是Unmarshalling Error: For input string: "",你的第一反应会是什么?我当时的第一反应是:响应XML里肯定有非法字符,多半是转义或者编码问题。顺着这个思路折腾了一整天,最后发现根因跟XML解析本身关系不大,问题出在字段类型映射和空值处理上。这篇就是一次Java客户端通过Axis调用WebService服务端接口的完整排障记录,除了这个异常本身,我还会把Axis调用SOAP接口常见的坑一起盘一遍,供还在跟Axis打交道的朋友参考。
1. 异常现场:批量任务里突然翻车的WebService接口
1.1 项目背景与技术栈
先说背景。这个项目是典型的遗留系统,JDK 1.6,客户端用 Axis 1.4 生成了一套 WSDL2Java 存根,定时任务每天从服务端同步一批订单状态。服务端由另一个团队维护,技术栈不一样,对外暴露的是标准 SOAP 接口。平时单笔调用都是好的,批量任务偶尔会翻车,但之前翻车大多是超时或者连接重置,重启任务就能继续跑。这次不一样,连续几批数据全都挂在同一个异常上,而且从日志看,失败的任务停滞在解析服务端返回数据的环节,前面的网络请求都是正常的。
技术栈本身没什么特别的,Axis 1.4 在 2006 年之后就基本停止维护了,但这类老接口在存量系统里非常常见。尤其当你负责的系统同时对接好几个不同团队的服务端时,WSDL 版本、SOAP 编码风格、序列化细节稍有差异,客户端就会表现得非常脆弱。这次的异常就是最典型的一个例子:服务端返回了一段结构合法但语义非法的XML,Axis 在反序列化阶段直接炸了。
1.2 报错信息的完整形态
把日志翻出来,核心异常长这样:
AxisFault faultCode: {http://schemas.xmlsoap.org/soap/envelope/}Server.userException faultString: Unmarshalling Error: For input string: "" faultActor: null faultDetail: {http://xml.apache.org/axis/}stackTrace: java.lang.NumberFormatException: For input string: "" at java.lang.NumberFormatException.forInputString(NumberFormatException.java:48) at java.lang.Integer.parseInt(Integer.java:456) at java.lang.Integer.valueOf(Integer.java:582) at org.apache.axis.encoding.ser.SimpleDeserializer.makeValue(SimpleDeserializer.java:93) at org.apache.axis.encoding.ser.SimpleDeserializer.onEndElement(SimpleDeserializer.java:227) at org.apache.axis.encoding.DeserializationContext.endElement(DeserializationContext.java:396) ...堆栈里最底层的根因是java.lang.NumberFormatException: For input string: ""。上面被 Axis 包了一层 Unmarshalling Error。Unmarshalling 就是反序列化,意思是 Axis 把 SOAP 响应解析成 Java 对象的过程中出了问题。但真正有价值的是最后半句:For input string: ""。
如果对 Java 的数字解析比较熟,看到这句基本就能反应过来:有人把一段空字符串传给了数字类型转换方法。但当时在问题现场,我没能第一时间从这段堆栈里读出这个信号,而是被"Unmarshalling"这个字眼带偏了方向,白白浪费了不少时间。
1.3 第一时间的误判
我最初的排查方向错得挺离谱。首先怀疑 XML 里有特殊字符被人为截断,响应报文不完整;其次怀疑是中文字符编码乱掉导致解析失败。这些怀疑听着合理,但有一个致命的问题:我压根没去看真实的响应报文,全凭异常栈猜。后来复盘发现,如果一开始就严格从报文入手,这个错最多半小时就能定位。
为什么会被带偏?因为Unmarshalling Error这个措辞太有迷惑性了,它强调的是"解组失败",而不会直接告诉你"这里有个空字符串要转 int"。Axis 作为老一代 SOAP 引擎,异常信息做得并不友好,很多关键细节都被吞掉了。排障的第一原则应该是:不要对着异常栈脑补,而是把触发异常的原始数据拿来看一眼。
2. Unmarshalling Error 到底在抱怨什么:Axis 反序列化的执行路径
2.1 序列化与反序列化的本质
WebService 调用本质上是把 Java 对象编码成一段 SOAP XML 发出去,再把服务端返回的 XML 解码回 Java 对象。这个往返过程叫 marshalling / unmarshalling。发请求时把 Java 对象转成 XML 叫序列化,收到响应把 XML 转成 Java 对象叫反序列化。
多数 Java 开发者熟悉 JSON 序列化框架,SOAP 这边的机制类似,但 XML 结构更严格、Schema 约束又多,出错时反馈反而更绕。Axis 的做法是:根据 WSDL 里的类型声明,为每个元素选择一个对应的 Deserializer,让它负责把 XML 文本转换成 Java 类型。比如xsd:int对应 Integer 解析,xsd:decimal对应 BigDecimal 解析,xsd:string对应 String 解析。
整个过程可以简单类比成快递入库:XML 报文是快递单,Java 对象是货架上的位置。反序列化就是把快递单上的信息填进货架格子里。如果单子上"数量"那一栏是空的,而货架的格子又必须要一个数字,麻烦就来了。
2.2 Axis 反序列化器的具体行为
Axis 在解析响应 Body 时,会把每个元素文本交给对应的 Deserializer。基本类型(int、long、double、BigDecimal)走的是SimpleDeserializer这个类。它做的事很简单:取出 XML 元素里的文本内容,再通过类型对应的 parse 或 valueOf 方法转换成 Java 对象。
以 Integer 为例,最终调用的就是Integer.valueOf(text)。一旦 text 是空字符串,NumberFormatException 立刻就会抛出来,错误信息就是For input string: ""。如果 text 恰好是一段有空格的内容,比如" ",报错信息里就会显示一个空格,很多人容易看漏。
所以这个报错虽然是Unmarshalling Error,但本质上就是数据类型转换失败。跟 XML 转义、字符编码基本没有关系。理解了这条执行路径之后,排查方向就清晰多了:你要找的,一定是某个被当作数字类型声明的字段,在响应报文中却传了空值。
2.3 空字符串有哪几种出现方式
关键问题是:响应报文里什么情况下会出现空字符串?根据我遇到的问题和后来的验证,有这么几类常见情况:
- 元素存在但文本为空:
<totalAmount></totalAmount>,等价于空字符串。 - 元素自闭合:
<totalAmount/>,Axis 解析出的文本同样是空串。 - nil 标记:
<totalAmount xsi:nil="true"/>,如果 WSDL 里没声明为 nillable,反序列化时同样可能出问题。 - 空白字符:
<totalAmount> </totalAmount>,看起来像空格,转成字符串后一样会让数字解析失败。
这里有个容易忽略的点:空元素、自闭合元素和 nil 标记在 XML 语义上是三回事。空元素表示"有一个元素但没有内容",nil 表示"这个元素本身就是空值"。Axis 对它们的处理不完全一样,但结果都可能落在同一个 NumberFormatException 上。
这也是为什么我一直强调要先抓报文。不看到真实的 XML,你永远不知道是上面哪一种情况,后面的修复方案也就无从谈起。
3. 逐步排查:从异常栈到根因的完整链路
3.1 第一步:抓报文,把 SOAP 请求和响应完整打出来
排障第一步必须是拿到真实的 SOAP 请求和响应,这个不能省。在能连外网的开发机上,最方便的是用 Axis 自带的 tcpmon 工具。它本质上是一个代理,在你本地监听一个端口,客户端 endpoint 指向这个本地端口,tcpmon 再把流量转发到真实服务端,同时在界面上把请求和响应报文全部打印出来。
java -cp axis.jar org.apache.axis.utils.tcpmon 8081启动之后,把客户端访问地址改成http://localhost:8081/你的服务路径,然后跑一次会失败的任务,tcpmon 界面上就能看到原始的 XML 报文。我那次抓到的响应里,Body 部分有一个字段长这样:
<totalAmount></totalAmount>整个 XML 结构是完整的,没有截断,没有非法字符,编码也完全正常。到这里,我之前的两个误判基本都被推翻了。
如果没有图形界面,或者想在服务器上离线排障,可以给 Axis 加一个日志 Handler,把经过的 SOAP 消息打出来。思路是写一个继承BasicHandler的类,在invoke方法里读取context.getRequestMessage()和context.getResponseMessage(),输出到日志文件。用 WSDL2Java 生成的存根,可以在拿到 Stub 之后往 EngineConfiguration 里注册这个 Handler。具体 API 因版本略有差异,核心思路就是把报文打出来,别让 Axis 帮你藏着。
3.2 第二步:对照 WSDL,逐字段核对类型声明
拿到报文之后,别急着猜。把响应 Body 里每个元素和 WSDL 里的 element 声明逐个对照。我那次抓到的响应里,totalAmount对应的 WSDL 声明是这样的:
<xsd:element name="totalAmount" type="xsd:decimal"/>WSDL 里是xsd:decimal,客户端生成的 Java 类型是java.math.BigDecimal。Axis 拿到空文本要执行new BigDecimal(""),不可能成功。到这里,矛头已经明确指向totalAmount这个字段的空值。
顺着这个思路,我把响应里其他数字字段也全部过了一遍,还真发现另外两个字段同样存在空值隐患,只是这次还没触发。如果不做这一步,即使这次修好了,下次可能还会在别的字段上栽跟头。
3.3 第三步:最小复现,锁定根因
为了确认到底是不是这个字段,我做了个最小复现:把正常响应报文里totalAmount替换成空串,用本地一个最小客户端请求一遍,异常必现。再把空串改成 0,任务立刻通过。这样基本就锁定了根因:服务端在某个数据条件下返回了空元素,客户端类型绑定要求数字,Axis 反序列化时必然炸。
最小复现的意义在于,它把复杂的线上问题收敛成了一个确定性实验。你不需要反复跑批量任务验证,也不需要猜测是不是瞬时的网络问题。只要一个字段能稳定复现,根因就八九不离十了。
4. 修复方案落地:改服务端还是改客户端
4.1 方案一:推动服务端规范化空值
最优解永远是推动服务端改。无值的情况下,要么不返回这个元素,要么返回 0,要么返回标准的xsi:nil="true"。对服务端团队来说,这是一个很小的改动,但对客户端稳定性提升很大。我在沟通时直接给出了报文截图和 WSDL 比对结果,对方很快就看明白了,当天就把问题修复了。
这里有个沟通技巧:不要只说"你那边报错了",而是把你的证据链整理清楚——异常栈、响应报文、WSDL 声明三样东西放到一起。跨团队沟通时,证据越具体,对方响应越快。
4.2 方案二:把字段声明为可空并使用包装类型
如果服务端坚持在无值时返回 nil,客户端这边要把 WSDL 里对应元素改成nillable="true",重新生成存根后,字段类型会从基础类型变成包装类型。比如 int 变 Integer,decimal 变 BigDecimal。在业务代码里对 null 做兜底处理就行。
需要注意,如果元素只是空文本而不是 nil,包装类型同样可能报错。所以这个方案最好和服务端约定好:无值时明确返回xsi:nil="true",而不是留一个空元素。Axis 对 nil 标记的处理会走向不同的代码路径,通常能正确映射成 Java 的 null,这样客户端代码就只需要判断 null,不用再担心解析异常。
4.3 方案三:绕开 Axis 自动映射,直接解析 SOAPBodyElement
如果你对服务端数据质量实在没信心,又不方便推动对方改,那就别依赖 Axis 的类型绑定。用一个返回org.apache.axis.message.SOAPBodyElement的 Call,拿到 Body 后自己按 DOM 遍历,对所有数字字段做空值判断再转换。代码会多一点,但整个解析逻辑完全在你手里,以后再遇到类似数据,不会连排障都要看 Axis 脸色。
Call call = new Call("targetEndpoint"); call.setOperationName(new QName("http://service.example.com/", "getOrderCount")); call.setReturnType(org.apache.axis.Constants.XSD_ANY); Object result = call.invoke(new Object[]{...}); if (result instanceof SOAPBodyElement) { SOAPBodyElement body = (SOAPBodyElement) result; // 遍历子元素,自行处理空字符串、nil 等情况 }这个方案不适合大规模改造,但非常适合作为应急手段。线上已经炸了,服务端又没法立刻改的时候,客户端先用这种方式把数据接住,是成本最低的止血方案。
4.4 我的最终选择与验证
我当时的选择是方案一加方案二一起做:服务端把空值改成返回xsi:nil="true",客户端把totalAmount对应字段从原始类型改成包装类型,业务代码里对 null 单独处理。
改完以后,我用线上的一批历史失败数据重新跑,全部通过。Batch 任务连续跑了三天,没有再翻车。这里特别说一句:验证的时候不要只跑一条成功用例就收工,把之前失败过的数据样本都回放一遍,才算真正验证完。
5. Axis 客户端调用 WebService 的常见雷区盘点
5.1 参数顺序错乱带来的语义偏移
用 Call 动态调用时最容易犯的错是不看 WSDL、想当然地按自己理解的顺序 addParameter。SOAP 消息里的参数位置错了,服务端不一定报错,而是把 A 参数的值当成 B 参数,等你拿回结果时才发现数据对不上。这种错比异常还难排查,因为程序不报错,只有业务数据看起来怪怪的。
建议能用 WSDL2Java 生成存根就用存根,动态调用只适合接口频繁变化的场景。任何情况下,对照 WSDL 里的 message 定义,搞清楚参数顺序,再动手拼请求。
5.2 命名空间不一致导致的各种怪异报错
Axis 对命名空间极其敏感。operation 的 QName 写错、SOAPAction 带不带路径、元素命名空间里多个或少个斜杠,都可能导致 No such operation 或者反序列化失败。这种报错看起来像是服务端接口不存在,但实际只是 namespace 字符串对不上。
排查这类问题,直接打开 WSDL 复制命名空间,别手敲,别凭记忆补前缀。多一个字符少一个字符,Axis 都会给你颜色看。
5.3 日期时间格式差异
跨语言 WebService 对接时,日期时间最容易踩坑。不同服务端序列化的格式不一样,有的带毫秒和时区,有的是老式写法。Axis 解析不认识的日期格式,同样会报Unmarshalling Error。这个场景跟本文的空字符串异常很相似,但根因是格式不兼容。
稳妥做法是把日期字段先映射成 String,自己在代码里按服务端实际格式解析。虽然多写几行代码,但至少不会让整个任务挂掉。日期格式的兼容代码,值得放进一个公共工具类里。
5.4 复杂嵌套类型的反序列化错位
返回对象越复杂,Axis 的坑越多。比如服务端新增了一个字段,老客户端解析时序列化顺序对不上,可能出现字段值错位。这类问题没有一个统一的报错特征,有时候只能看到数值诡异。
这类问题通常不是靠排障能解决的,而是靠规范:服务端 WSDL 变更后,客户端必须同步用工具重新生成代码,不要手改 Bean。手改一时爽,后期维护火葬场。
5.5 超时、连接池与重试
Axis 1.4 默认用的 HTTP 客户端是 HttpURLConnection,超时配置需要走系统属性:
System.setProperty("sun.net.client.defaultConnectTimeout", "10000"); System.setProperty("sun.net.client.defaultReadTimeout", "30000");如果想要更精细的连接管理,可以换成 CommonsHTTPSender 配合 HttpClient。另外重试要区分业务异常和网络异常,Unmarshalling Error这种解析类问题重试多少次都没用,先把数据修对再谈重试。
| 雷区 | 典型表现 | 处理思路 |
|---|---|---|
| 参数顺序错乱 | 数据语义错位但不报错 | 用存根,按WSDL顺序传参 |
| 命名空间不一致 | No such operation / 找不到反序列化器 | 从WSDL复制命名空间 |
| 日期格式差异 | Unmarshalling Error | 先映射String再自行解析 |
| 复杂嵌套类型错位 | 字段值诡异 | 同步重新生成代码 |
| 超时配置缺失 | Connect timed out / Read timed out | 设置系统属性或换HTTP客户端 |
这次排障最值钱的经验不是记住了这个异常,而是养成了一个习惯:遇到 Axis 解析类错误,先抓报文再谈原理。报文在手,根因基本能定位到具体字段;对着异常栈猜,十次有九次要绕远路。另外,如果你们团队也在维护这类老接口,建议把 WSDL、报文样例、已知坑位整理成一个文档,给后来人省点时间。Axis 虽然老,但它承载的业务还在跑,这些经验的保质期,可能比我们想象中长得多。