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 即可同步生效,无需重新注入。
相关推荐
深蓝AI17 小时前
JWT 不是登录方案:三种登录态架构怎么选
后端
starzy199017 小时前
深入理解Scala Map:从实现原理到工程实践
开发语言·后端·scala
卷无止境18 小时前
凌晨三点的绿灯
后端
excel19 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
风若飞20 小时前
Spring Ai 入门遇到问题1
java·后端·spring
AI入职记21 小时前
Spring AI 结构化输出:结构 100% 通过,严格准确率只有 57%
后端·ai编程
aZhe的全栈知识分享21 小时前
AI 还原 Figma 效果总达不到预期?先别急着让它写代码
前端·后端·面试
码事漫谈21 小时前
WorkBuddy 一直更新卡死?两个毛病,一套处理办法
后端
函数小陈21 小时前
阿里云部署DSH与RAM完成运维
前端·后端
aurora_zjj21 小时前
一次持续 3 年的 OpenResty 死循环排查:最终定位到 LuaJIT ARM64 的一条汇编指令
后端