2315 字
约 7 分钟
17
S07-A20 · 参数解析器:`@RequestBody` 之前发生了什么

一个接口的 @RequestBody 参数里,某个字段总是 null——而请求体里明明有它。查下来是一个自定义的 HandlerMethodArgumentResolver 注册在默认解析器之前,抢先处理了这个类型,而它只填了自己关心的字段。

Spring 的参数解析是一条"责任链":按顺序问每个解析器"你支持这个参数吗",第一个说"支持"的赢。这个"不报错"的性质来自机制本身:责任链的第一个"支持者"直接赢,而"支持"是布尔判断——于是"过度声明支持"不会被拒绝,只会安静地改变行为。
而"自定义解析器抢在最前面"是这类问题的常见形态——它不报错,只是让默认行为不再发生。


1. 解析器的两种类型

类型 负责
HandlerMethodArgumentResolver 方法参数(@RequestBody、@RequestParam)
HandlerMethodReturnValueHandler 返回值(@ResponseBody、ResponseEntity)

两者是"成对"的:如果你自定义了参数解析,往往也要考虑"返回值怎么处理"。两者的"成对"在"自定义对象"上尤其明显:参数能解析出来,但返回值可能被序列化成意外结构(因为默认的 HttpMessageConverter 不认识你的类型)。
而"只加了参数解析"是最常见的半成品。

// 注册自定义解析器:默认"排在最后",但很多人会 setOrder(0) 抢最前
@Configuration
public class ResolverConfig implements WebMvcConfigurer {
    @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new MyUserResolver());          // 追加 → 排在内置之后(安全)
        // resolvers.add(0, new MyUserResolver());     // 插到最前 → 可能抢掉内置类型(危险)
    }
}

2. "抢在最前"的三种后果

后果 表现
内置解析不再生效 字段为 null、注解被忽略
类型判断过宽 supportsParameter 返回 true 太多
顺序依赖 加/删解析器改变行为

第二项最坏的写法是"return true"(用来"接管所有参数"),而它在开发期"看起来方便",上线后让所有接口的默认行为消失——包括 @RequestParam 的必填校验。
第二项是根因:supportsParameter 写得宽(如"所有带某注解的参数"或"所有 POJO"),于是它把本该交给内置解析器的类型也接了过去。

而"字段为 null"是最难查的表现:没有异常、没有警告,只是某个字段永远是 null。

3. 参数解析的完整链路

阶段 动作
1 找到处理方法 HandlerMapping
2 逐个参数问解析器 supportsParameter
3 解析(读 body/query/header) resolveArgument
4 校验(@Valid) 校验器
5 调用方法 HandlerAdapter
6 处理返回值 HandlerMethodReturnValueHandler

第三项是"自定义解析器"的入口:它自己负责读请求体/查询参数,而"读哪个、怎么读"没有约束第三项的"陷阱"在"读请求体":@RequestBody 的流只能读一次,于是"自定义解析器读了 body 但没返回给内置解析器"会表现为"内置解析器拿到空 body"。
——于是"读了一半"很常见。

第四项还有一个"组校验"细节:@Validated 的分组校验在解析器里不会自动应用,于是"分组校验"在自定义解析的路径上静默失效。
第四项的顺序要注意:校验发生在解析之后,所以"解析器漏填字段"不会被 @Valid 发现(除非该字段标了 @NotNull)。

4. 三种安全的定制方式

方式 适用范围
追加解析器(默认顺序) 只处理新类型
高优先级但判断严格 需替换内置行为
用 @ModelAttribute/自定义注解 结构化的入参

第一项最安全:只处理"内置不支持的类型",于是不会抢任何东西。第一项的落地方式是"先测再放":写一个"只有我关心的类型"的 supportsParameter,并给它写单测——断言"对 String/Map/Optional 返回 false"。

第三项是"更 Spring 的方式":用自定义注解 + 解析器,让"哪些参数走我的逻辑"显式写在签名上。

// 安全的自定义:只在"带了我的注解"时接管
public class MyUserResolver implements HandlerMethodArgumentResolver {
    @Override public boolean supportsParameter(MethodParameter p) {
        return p.hasParameterAnnotation(CurrentUser.class);   // 严格判断
    }
    @Override public Object resolveArgument(...) {
        return SecurityConte第四项(某些接口正常某些不正常)的规律是"**看参数类型**":如果"简单类型正常、自定义类型异常",那几乎一定是"自定义解析器支持范围过宽"。
xt.currentUser();                 // 只填这一个参数
    }
}

"解析器顺序"可以直接打印出来,比猜快得多:

// 启动时打印参数解析器的顺序(排查期用)
@Component
public class ResolverOrderDump implements ApplicationListener<ApplicationReadyEvent> {
    public void onApplicationEvent(ApplicationReadyEvent e) {
        var bean = e.getApplicationContext().getBean(RequestMappingHandlerAdapter.class);
        int i = 0;
        for (var r : bean.getArgumentResolvers().getResolvers()) {
            log.info("resolver[{}] {}", i++, r.getClass().getSimpleName());
        }
    }
}
// 若你的解析器出现在第 0 位,且 supportsParameter 写得宽,就是它

5. 怎么确认"是解析器抢了"

手段 得到
断点/日志在 supportsParameter 谁接手了
@RequestBody 换成 String 看原始体 请求体是否有值
临时移除自定义解析器 行为是否恢复
打印解析器列表顺序 谁在最前

第二项最快:把参数改成 String 直接接原文第二项之所以最快,是因为它把"两层可能"减到"一层":如果原文有值,问题一定在"解析/映射";如果原文也没值,问题在"客户端或过滤器"。
,若原文有值而对象里没值,问题在"解析/映射"这一段。

第三项最直接:注释掉自定义注册,看是否恢复——一分钟就能定位"是不是它"。

"自定义解析器"还有一个"测试"上的要求:给它写"不该支持"的用例,而不是只测"支持"。

// 单测:断言"不接管"的情况(比测"支持"更重要)
@Test void 不接管String参数() {
    var param = new MethodParameter(methodWithStringParam, 0);
    assertFalse(new MyUserResolver().supportsParameter(param));
}
@Test void 不接管Map参数() { ... }
// 原因:抢掉内置类型的 bug,恰恰发生在"你没测的那些类型"上

6. 排错:五种"参数相关问题"

现象 成因 处理方向
字段总是 null 自定义解析器抢了 检查 supportsParameter
@Valid 不生效 自定义解析器自己 new 对象 解析器内不校验
注解被忽略 解析器顺序 追加而非插前
某些接口正常某些不正常 参数类型不同 缩小支持范围
上传/表单失效 解析器不支持 multipart 显式排除

第二项的"变体"是"解析器返回了一个代理对象"(如 Spring 的 @ModelAttribute 行为),于是"校验能生效但发生在不同的时机"——排查时容易误判。
第二项很隐蔽:解析器里 new 出来的对象不走 @Valid,于是"校验看起来配了但不生效"。

"返回值"这一侧也有对应的坑:自定义 HandlerMethodReturnValueHandler 注册在最前,会让 @ResponseBody 失效——于是"接口返回了对象,客户端收到 404 或空白"。

// 返回值处理器的安全注册(追加,而不是插前)
@Override public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
    handlers.add(new MyResultHandler());        // 追加 → 只处理它认识的类型
}
// 反例:handlers.add(0, new MyResultHandler()) → 可能抢掉 @ResponseBody

7. 边界:什么情况不需要自定义解析器

场景 为什么 做法
只是"取当前用户" 可用 SecurityContext 直接取 不必
只是"类型转换" 用 Converter/Formatter 不必
只是"默认值" 用 @RequestParam(defaultValue) 不必
只是"DTO 组装" 在 Controller 里一行转换 不必

判断口径一句话:这个动作需要在"所有接口"上以同一种方式发生吗。最后一条与"顺序"有关的边界:"追加"与"插入最前"的差别,应当被写进团队约定。因为后者是"全局影响",而它在代码里只是一行 add(0, ...)——看起来像"小改动"。
会(统一身份注入、统一脱敏),才值得做解析器;只在个别接口,用一行代码更清楚。


8. 自测

  1. 参数解析的"责任链"机制是什么?为什么"第一个说支持的赢"会带来问题?
  2. supportsParameter 写得过宽会造成什么?"字段总是 null"为什么难查?
  3. 参数解析链路的六个阶段里,校验发生在哪一步之后?它为什么发现不了"解析器漏填"?
  4. 三种安全定制方式里,哪一种最 Spring?它把"接管范围"写在哪?
  5. "把参数改成 String 接原文"能验证什么?
  6. 判断"是否需要自定义解析器"的那个问题是什么?
S07-A20 · 参数解析器:`@RequestBody` 之前发生了什么
https://www.clxhxhhr.top/posts/6569/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。