本节目标:把「HTTP 请求变成方法入参」这条链路讲透——参数解析器的选择顺序、
@RequestBody的消息转换器选型、以及自定义解析器与转换器插在哪里才生效。
适用版本:Spring Boot 4.1.x(Java 21)
4.2 参数解析与消息转换
上一节讲完 ha.handle() 被调用,这一节接着讲它内部:方法签名上的每一个参数,框架是怎么从请求里凑出来的。仍用图书借阅服务 LibraryController:
@RestController
@RequestMapping("/books")
public class LibraryController {
@GetMapping("/{isbn}")
public Book get(@PathVariable String isbn) { ... }
@GetMapping("/search")
public List<Book> search(@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page) { ... }
@PostMapping
public Book create(@RequestBody CreateBookRequest request) { ... }
@PostMapping("/form")
public Book createForm(@ModelAttribute CreateBookRequest request) { ... }
}
这四种参数是日常 90% 的场景,它们分别由四个不同的解析器负责,且顺序决定了谁先被选中。
4.2.1 参数解析的入口:InvocableHandlerMethod
RequestMappingHandlerAdapter.invokeHandlerMethod() 会把 HandlerMethod 包成 ServletInvocableHandlerMethod,并注入参数解析器组合:
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
invocableMethod.setDataBinderFactory(binderFactory);
真正解析发生在 InvocableHandlerMethod.getMethodArgumentValues()(Framework 7.0.9 源码):
MethodParameter[] parameters = getMethodParameters();
Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (!this.resolvers.supportsParameter(parameter)) {
throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
}
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
两个要点:一是逐个参数独立解析,互不影响;二是找不到解析器会直接抛 IllegalStateException(不是 400),这属于编程错误——意味着方法签名用了框架不支持的类型。
4.2.2 解析链与缓存:HandlerMethodArgumentResolverComposite
this.resolvers 是 HandlerMethodArgumentResolverComposite,它内部维护一个解析器列表和一个 argumentResolverCache:
public HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
这里有两个必须理解的点:
- 顺序即优先级:
supportsParameter()返回真的第一个解析器被选中,后面的不再问。所以自定义解析器想抢在某个内置解析器之前生效,就得插到它前面(见 4.2.5)。 - 缓存键是
MethodParameter:同一个控制器方法的同一个参数位置,第一次匹配后结果被缓存,后续请求不再遍历列表。但缓存是在运行时按MethodParameter对象做 key 的——如果你在自定义解析器里依赖请求内容来动态决定supportsParameter(),缓存会把它钉死成第一次的判断结果。
4.2.3 默认解析器清单与顺序
RequestMappingHandlerAdapter.getDefaultArgumentResolvers() 定义了默认顺序(7.0.9 源码,节选前段):
| 顺序 | 解析器 | 负责的参数 |
|---|---|---|
| 1 | RequestParamMethodArgumentResolver(false) | @RequestParam、简单类型无注解参数 |
| 2 | RequestParamMapMethodArgumentResolver | @RequestParam Map<String,String> |
| 3 | PathVariableMethodArgumentResolver | @PathVariable(单值) |
| 4 | PathVariableMapMethodArgumentResolver | @PathVariable Map<String,String> |
| 5 | MatrixVariableMethodArgumentResolver | @MatrixVariable |
| 6 | ServletModelAttributeMethodProcessor(false) | @ModelAttribute、非简单类型无注解参数 |
| 7 | RequestResponseBodyMethodProcessor | @RequestBody |
| 8 | RequestPartMethodArgumentResolver | @RequestPart(多部件) |
| 9 | RequestHeaderMethodArgumentResolver | @RequestHeader |
| 10 | ServletCookieValueMethodArgumentResolver | @CookieValue |
| … | (@SessionAttribute、@RequestAttribute、ServletRequest、ServletResponse 等) | 类型驱动 |
| 末 | PrincipalMethodArgumentResolver | java.security.Principal |
| 末 | RequestParamMethodArgumentResolver(true) | 兜底:任意可转字符串的简单类型 |
| 末 | ServletModelAttributeMethodProcessor(true) | 兜底:任意非简单类型当表单对象绑定 |
清单里的顺序是硬编码在 getDefaultArgumentResolvers() 里的,不是通过 @Order 或排序器动态决定的;这也是为什么自定义解析器插队必须靠 setArgumentResolvers() 重排,而不是给它加个 Ordered。
注意两个 (false) / (true) 构造参数:annotationNotRequired。false 表示必须带注解才认领;true 是兜底处理器——不带任何注解的参数,简单类型交给 RequestParamMethodArgumentResolver(true) 当查询参数,复杂类型交给 ServletModelAttributeMethodProcessor(true) 当表单对象绑定。这就解释了为什么 public Book get(String isbn) 这种「没写 @RequestParam」的写法也能工作。
4.2.4 四个高频参数各归谁
@PathVariable String isbn → PathVariableMethodArgumentResolver。它在启动阶段从 @PathVariable 的值或参数名推出变量名,运行时从 HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE 这个请求属性里取(由 AbstractHandlerMethodMapping.handleMatch() 填入),再经 WebDataBinder 做类型转换。缺失变量抛 MissingPathVariableException,最终由 DefaultHandlerExceptionResolver 转成 500。
@RequestParam String keyword → RequestParamMethodArgumentResolver(false)。它从 request.getParameterValues(name) 取值,缺失且无 defaultValue 且 required=true 时抛 MissingServletRequestParameterException → 400。defaultValue 为空的字符串会触发「空串转默认值」的特殊处理,这点在表单场景要留意。
@ModelAttribute CreateBookRequest request → ServletModelAttributeMethodProcessor(false)。它走的是数据绑定而非消息转换:createAttribute() 反射实例化对象,WebDataBinder 把请求参数按属性名逐个 set 进去,顺带跑 @Valid 校验与 BindingResult。这就是 @ModelAttribute 与 @RequestBody 的根本区别——前者是「键值对填字段」,后者是「整体反序列化」。
@RequestBody CreateBookRequest request → RequestResponseBodyMethodProcessor。它不做字段级绑定,而是把整个请求体交给 HttpMessageConverter。这是下一小节的重点。
4.2.5 @RequestBody 的 HttpMessageConverter 选型规则
RequestResponseBodyMethodProcessor 继承自 AbstractMessageConverterMethodArgumentResolver,核心方法是 readWithMessageConverters()(7.0.9 源码逻辑):
MediaType contentType;
try {
contentType = inputMessage.getHeaders().getContentType();
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(ex.getMessage(), getSupportedMediaTypes(...));
}
if (contentType == null) {
noContentType = true;
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter<?> genericConverter) {
if (genericConverter.canRead(targetType, contextClass, contentType)) { ... }
}
else if (targetClass != null && converter.canRead(targetClass, contentType)) { ... }
// 命中后:converter.read(...) 或 handleEmptyBody(...),然后 break
}
选型规则可以归纳成三条:
- 看
Content-Type:请求头没有Content-Type时按application/octet-stream处理(noContentType = true),这会导致大多数 JSON 转换器canRead为假,最终 415。 - 按顺序试:
this.messageConverters是有序列表,第一个canRead(目标类型, contentType)为真的转换器被选中。canRead要同时匹配媒体类型和目标 Java 类型——例如StringHttpMessageConverter只认String,ByteArrayHttpMessageConverter只认byte[]。 - 失败即 415/400:没有任何转换器能读 →
HttpMediaTypeNotSupportedException(415);转换器能读但内容格式错 →HttpMessageNotReadableException(400)。
canRead 的类型参数在 7.0.9 里分三档:GenericHttpMessageConverter.canRead(Type, Class, MediaType)(能处理泛型,如 List<Book>)、SmartHttpMessageConverter.canRead(ResolvableType, MediaType)(能拿读提示)、以及基础 canRead(Class, MediaType)。泛型集合反序列化依赖的就是 GenericHttpMessageConverter。
JSON 的默认转换器在 4.x 是 Jackson 3(tools.jackson 包),由 spring-boot-http-converter 模块的 HttpMessageConvertersAutoConfiguration 装配;类改名见 @JacksonComponent(原 @JsonComponent)与 JsonMapperBuilderCustomizer(原 Jackson2ObjectMapperBuilderCustomizer)。自定义 ObjectMapper bean 在 4.x 不再能替换自动配置的 JsonMapper——这是 Jackson 3 迁移里最容易踩的坑。
4.2.6 自定义解析器与转换器的注册位置
自定义参数解析器:实现 HandlerMethodArgumentResolver,通过 WebMvcConfigurer.addArgumentResolvers(List<HandlerMethodArgumentResolver>) 注册。它被插入的位置很关键——回到 4.2.3 的清单,自定义解析器加在「类型驱动解析器之后、兜底解析器之前」,即所有内置解析器之后。想让它在某个内置解析器(比如 RequestResponseBodyMethodProcessor)之前生效,光用 addArgumentResolvers 不够,得走 RequestMappingHandlerAdapter.setCustomArgumentResolvers() 或直接 setArgumentResolvers() 重排。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserArgumentResolver());
}
}
自定义消息转换器:两个方法语义不同——
configureMessageConverters(List<HttpMessageConverter<?>>):完全替换默认列表,返回false(默认)表示不追加默认转换器。慎用。extendMessageConverters(List<HttpMessageConverter<?>>):在默认列表基础上增删改,是推荐做法。
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new MyCustomConverter()); // 插到最前,优先被 canRead/canWrite 命中
}
注意 converters.add(0, ...) 让自定义转换器排在最前,它就能抢在 Jackson 之前处理特定类型——这是「给某个类定制序列化」的常见手法。
4.2.7 参数名从哪来:ParameterNameDiscoverer
@PathVariable 和 @RequestParam 允许省略名字(如 @PathVariable String isbn),框架得知道参数叫 isbn。这个信息由 ParameterNameDiscoverer 提供,InvocableHandlerMethod.getMethodArgumentValues() 开头那句 parameter.initParameterNameDiscovery(this.parameterNameDiscoverer) 就是干这个的。
在 Java 8 之前,反射拿不到参数名,所以老代码必须写 @PathVariable("isbn")。现代做法有两种:
- 编译期保留参数名:
javac -parameters(Maven 下maven-compiler-plugin的<parameters>true</parameters>,spring-boot-starter-parent已默认开启)。 - 本地变量表调试信息:
-g编译,LocalVariableTableParameterNameDiscoverer从字节码里读。
若两者都没有(例如用 -g:none 编译、或某些 Kotlin/Groovy 组合),省略名字的注解会在启动时报「Name for argument of type [java.lang.String] not specified」——这是很隐蔽的构建配置问题,排查时要先确认字节码里到底有没有参数名。
验证方法:用 javap -v YourController.class | rg 'MethodParameters' 看目标方法有没有 MethodParameters 属性;没有就说明没开 -parameters。
4.2.8 知道之后能做什么
排障:参数是 null。 打开 logging.level.org.springframework.web=TRACE,InvocableHandlerMethod 会打印 Arguments: [...],直接看到每个参数解析后的值;配合断点下在 HandlerMethodArgumentResolverComposite.getArgumentResolver(),能看到某个参数命中了哪个解析器。
排障:415 Unsupported Media Type。 99% 是 Content-Type 与 @RequestBody 目标类型不匹配,或没带 Content-Type。用 curl -v 确认请求头,再检查目标类型是否被某个转换器的 canRead 覆盖。
扩展:自定义参数解析器。 例如从 Authorization 头解析出当前用户对象,让控制器方法直接写 public Book get(@CurrentUser User user, @PathVariable String isbn)。这类「横切关注点参数化」是解析器最典型的用途。
扩展:自定义转换器。 例如让某类对象统一序列化成扁平结构。优先用 extendMessageConverters + converters.add(0, ...),避免 configureMessageConverters 把默认 JSON 支持整个换掉。
验证:观察转换器列表。 在自定义 WebMvcConfigurer 的 extendMessageConverters 里打印 converters 的 getClass().getSimpleName(),或在 /actuator/mappings 之外配合断点查看 RequestMappingHandlerAdapter.getMessageConverters() 的完整顺序。
小结
- 参数解析入口是
InvocableHandlerMethod.getMethodArgumentValues(),逐参数独立解析,找不到解析器抛IllegalStateException(编程错误,不是 400)。 HandlerMethodArgumentResolverComposite按顺序取第一个supportsParameter()为真的解析器,并缓存到MethodParameter上。@PathVariable→PathVariableMethodArgumentResolver;@RequestParam→RequestParamMethodArgumentResolver;@ModelAttribute→ServletModelAttributeMethodProcessor(数据绑定);@RequestBody→RequestResponseBodyMethodProcessor(消息转换)。@RequestBody的转换器选型是「按Content-Type+ 目标类型逐个试canRead()」,全失败 415、格式错 400。- 自定义解析器经
addArgumentResolvers加在内置之后、兜底之前;自定义转换器用extendMessageConverters并add(0, ...)抢占优先级。 - 省略名字的
@PathVariable/@RequestParam依赖-parameters编译;字节码里没有MethodParameters属性时,启动就会报「Name for argument not specified」。
两条链路的分工也可以这样记:@ModelAttribute 走 WebDataBinder(逐字段 set,天然带 BindingResult),@RequestBody 走 HttpMessageConverter(整体反序列化,失败直接 400)。选错链路就会出现「字段明明传了却绑定不上」或「想收 BindingResult 却拿不到」这类问题。
参数进来了、返回值出去了,可请求在进入 handler 之前还隔着 Filter 与 Interceptor 两道关,异常也可能在其中任一层抛出——这是下一节的主题。
阅读导航:上一节:4.1 DispatcherServlet 处理链 · 下一节:4.3 过滤器、拦截器与异常解析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。