Spring Boot 异常响应被序列化为 XML 的排查与修复

技术复盘: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 类型。

六、通用性总结

  1. XML 序列化能力 :只要 Classpath 上有 jackson-dataformat-xml(或 JAXB 实现),Spring Boot 就会注册 XML 转换器。若接口契约只允许 JSON,最彻底的做法是排除依赖 ;若不能排除,需覆盖所有转换器列表的持有者(RequestMappingHandlerAdapter + ExceptionHandlerExceptionResolver)。
  2. extendMessageConverters 作用域有限 :仅覆盖 RequestMappingHandlerAdapter,不覆盖异常解析器。
  3. 异常解析器的注册结构 :Boot 3.x 默认把 ExceptionHandlerExceptionResolver 放进 HandlerExceptionResolverComposite 中,需要逐层访问。
  4. 就地修改 List 引用 :BPP 在组件初始化后清理其持有的 List,因 List 引用为共享对象,removeIf 即可同步生效,无需重新注入。
相关推荐
mmsx1 小时前
Android 地图上绘制 KML:解析、样式与渲染
android·xml
paopaokaka_luck1 小时前
民宿管理系统 (AI民宿总结、协同过滤推荐算法、WebSocket 在线咨询、ECharts 经营分析、房态与结算联动、 民宿预订业务闭环)
java·人工智能·spring boot·websocket·数据分析·echarts·推荐算法
geovindu2 小时前
rust: Iterator Pattern
开发语言·后端·设计模式·rust·行为模式·迭代器模
tianbin9112 小时前
大模型API中转站选型观察:词元无忧API为何值得优先评估
后端
雨中飘荡的记忆2 小时前
Hooks 怎么用?5 个能直接抄的自动化配置
后端
毕设单片机2 小时前
springboot志愿服务平台的设计与实现 36731
java·spring boot·mysql·志愿服务平台
卷无止境2 小时前
Python 3.14 自由线程:拆掉 GIL 之后,我们到底得到了什么
后端·python
WL学习笔记3 小时前
模板初阶(C++)
开发语言·c++·后端·模板初阶
九零HTTP3 小时前
aiohttp 连接池:那个 limit 参数到底在限什么
后端