技术复盘:Spring Boot 异常响应被序列化为 XML 的排查与修复
记录一次耗时较长的排查:正常接口返回 JSON,异常响应却被序列化为 XML。最终定位到是
ExceptionHandlerExceptionResolver持有独立的转换器列表,并给出稳定修复方案。
一、问题现象
在某 Spring Boot 3.x / Spring Framework 6.x 微服务中,出现了诡异的响应格式不一致问题:
| 场景 | 响应格式 |
|---|---|
正常业务请求(如 POST /api/user/update) |
JSON {"code":0,...} |
异常响应(如 POST /api/cache/refresh 触发 405) |
XML <CommonResult><code>300</code>... |
- 客户端请求头统一为
Accept: */* - 响应 HTTP 状态码均为 200,Body 内的
code/msg契约结构不变 - Controller 与 GlobalExceptionHandler 的声明均正常
二、根因分析
1. XML 转换器的来源
项目中某个中间件(如 ShardingSphere-JDBC)间接引入了 jackson-dataformat-xml,Spring Boot 检测到该依赖后,自动注册了 MappingJackson2XmlHttpMessageConverter。
在 Spring Framework 6.x 的 WebMvcConfigurationSupport#addDefaultHttpMessageConverters 中,XML 转换器的注册顺序早于 JSON:
text
[..., StringHttpMessageConverter, ResourceHttpMessageConverter,
MappingJackson2XmlHttpMessageConverter, ← XML
MappingJackson2HttpMessageConverter, ← JSON
...]
当请求头为 Accept: */* 或 application/xml 时,内容协商机制优先选中 XML → 统一响应体 CommonResult 被序列化成 <CommonResult>...</CommonResult>。
2. 为什么"正常 JSON、异常 XML"
这是本次排查最耗时的点。关键发现是:正常路径与异常路径持有两份独立的转换器列表。
| 路径 | 转换器列表宿主 | 是否经过 WebMvcConfigurer#extendMessageConverters |
|---|---|---|
| 正常响应 | RequestMappingHandlerAdapter |
✅ 会 |
| 异常响应 | ExceptionHandlerExceptionResolver |
❌ 不会 |
WebMvcConfigurer#extendMessageConverters 只影响 RequestMappingHandlerAdapter 那一份;而 ExceptionHandlerExceptionResolver 由 Boot 单独构建(默认包装在 HandlerExceptionResolverComposite 里),持有自己的一份转换器列表,不经过 extendMessageConverters 回调。
3. 为什么 @ExceptionHandler 方法拿不到清理结果
排查中还验证了两件事:
CommonResult无任何@Xml*/@JacksonXml*注解 → XML 只能由MappingJackson2XmlHttpMessageConverter输出,目标明确。ExceptionHandlerExceptionResolver没有独立 Bean ,被 Boot 包在HandlerExceptionResolverComposite中,导致简单使用instanceof ExceptionHandlerExceptionResolver的BeanPostProcessor根本匹配不到。
三、最终修复方案
核心配置类
java
@Slf4j
@Configuration
public class WebMvcConverterConfig implements WebMvcConfigurer {
// 1) 清理正常路径(RequestMappingHandlerAdapter)的 XML 转换器
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
int removed = removeXmlConverters(converters);
log.info("extendMessageConverters 移除 XML 转换器: {} 个", removed);
}
// 2) 清理异常路径(ExceptionHandlerExceptionResolver)的 XML 转换器
@Bean
public static BeanPostProcessor exceptionResolverXmlConverterCleaner() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
// resolver 可能是独立 bean,也可能被包在 Composite 里(Boot 3.x 默认),两种都处理
if (bean instanceof ExceptionHandlerExceptionResolver resolver) {
clean(resolver);
} else if (bean instanceof HandlerExceptionResolverComposite composite) {
for (HandlerExceptionResolver r : composite.getExceptionResolvers()) {
if (r instanceof ExceptionHandlerExceptionResolver ehr) {
clean(ehr);
}
}
}
return bean;
}
private void clean(ExceptionHandlerExceptionResolver resolver) {
// removeIf 就地修改同一 List 实例,resolver 已构建的返回值处理器同样生效
int removed = removeXmlConverters(resolver.getMessageConverters());
log.info("ExceptionHandlerExceptionResolver[{}] 移除 XML 转换器: {} 个",
resolver.hashCode(), removed);
}
};
}
private static int removeXmlConverters(List<HttpMessageConverter<?>> converters) {
int before = converters.size();
converters.removeIf(c -> c instanceof MappingJackson2XmlHttpMessageConverter);
return before - converters.size();
}
}
关键技术点
| 要点 | 说明 |
|---|---|
static @Bean |
BPP 需早于宿主配置类实例化,static 让它在普通 Bean 之前完成注册 |
HandlerExceptionResolverComposite 兼容 |
Boot 3.x 里异常解析器被包在 Composite 中,必须遍历其 getExceptionResolvers() |
removeIf 就地修改 |
BPP 在 Resolver 初始化之后 执行,但其返回值处理器持有的是同一 List 引用,removeIf 同步生效 |
getMessageConverters() |
ExceptionHandlerExceptionResolver 有 public getter,无需反射 |
为什么不直接排除依赖?
jackson-dataformat-xml 被中间件间接引入,强行排除有 NoClassDefFoundError 风险(中间件运行时可能读取 XML/YAML 配置)。BPP 方案零依赖风险,更为稳妥。
四、验证
启动日志确认
text
extendMessageConverters 移除 XML 转换器: 2 个
ExceptionHandlerExceptionResolver[xxxxx] 移除 XML 转换器: 2 个 ← 关键:证明异常路径也被清理
请求验证
bash
# 正常请求 → JSON
curl -X POST 'http://localhost:8080/api/user/update' \
-H 'Accept: */*' -H 'Content-Type: application/json' \
-d '{"id":"1","name":"test"}'
# 异常请求(POST 打 GET-only 接口)→ JSON
curl -X POST 'http://localhost:8080/api/cache/refresh' \
-H 'Accept: */*'
两条请求均返回 JSON,问题闭环。
五、排查历程中的关键误区
| 阶段 | 错误判断 | 修正 |
|---|---|---|
| 1 | 认为是 @ExceptionHandler 未声明 produces |
全局 20+ Handler 逐个加不现实,且不符合根因 |
| 2 | 认为是包路径未被扫描 | 通过容器内 ls + 启动日志确认类已被加载 |
| 3 | 认为是中间件依赖没排干净 | 排干净了但 BPP 逻辑不对,问题依旧 |
| 4 | BPP 用 instanceof ExceptionHandlerExceptionResolver 匹配 |
实际 Bean 是 HandlerExceptionResolverComposite,从未命中 |
| 5 | 想在 @RestControllerAdvice 类上加 produces |
@ExceptionHandler 不支持 produces 属性 |
💡 排查启示:
- 遇到"正常路径 OK、异常路径不 OK"的问题,优先怀疑两条路径使用的组件不是同一个实例。
- Spring MVC 中,
WebMvcConfigurer的配置未必覆盖所有 MVC 组件。 - 加日志是最便宜的判断手段------BPP 日志没打印比"打印了但没生效"信息量大得多。
BeanPostProcessor的instanceof匹配容易漏掉被包装的 Bean,注意Composite类型。
六、通用性总结
- XML 序列化能力 :只要 Classpath 上有
jackson-dataformat-xml(或 JAXB 实现),Spring Boot 就会注册 XML 转换器。若接口契约只允许 JSON,最彻底的做法是排除依赖 ;若不能排除,需覆盖所有转换器列表的持有者(RequestMappingHandlerAdapter+ExceptionHandlerExceptionResolver)。 extendMessageConverters作用域有限 :仅覆盖RequestMappingHandlerAdapter,不覆盖异常解析器。- 异常解析器的注册结构 :Boot 3.x 默认把
ExceptionHandlerExceptionResolver放进HandlerExceptionResolverComposite中,需要逐层访问。 - 就地修改 List 引用 :BPP 在组件初始化后清理其持有的 List,因 List 引用为共享对象,
removeIf即可同步生效,无需重新注入。