一个接口的 @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. 自测
- 参数解析的"责任链"机制是什么?为什么"第一个说支持的赢"会带来问题?
supportsParameter写得过宽会造成什么?"字段总是 null"为什么难查?- 参数解析链路的六个阶段里,校验发生在哪一步之后?它为什么发现不了"解析器漏填"?
- 三种安全定制方式里,哪一种最 Spring?它把"接管范围"写在哪?
- "把参数改成
String接原文"能验证什么? - 判断"是否需要自定义解析器"的那个问题是什么?