《Spring Boot 高级》4.2 参数解析与消息转换

拆开 HandlerMethodArgumentResolver 的解析链与默认顺序,讲清 @RequestParam / @PathVariable / @ModelAttribute 各归哪个解析器,再深入 @RequestBody 经 HttpMessageConverter 的选型规则,最后说明自定义 resolver 与 converter 的注册位置与优先级。

本节目标:把「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;
}

这里有两个必须理解的点:

  1. 顺序即优先级:supportsParameter() 返回真的第一个解析器被选中,后面的不再问。所以自定义解析器想抢在某个内置解析器之前生效,就得插到它前面(见 4.2.5)。
  2. 缓存键是 MethodParameter:同一个控制器方法的同一个参数位置,第一次匹配后结果被缓存,后续请求不再遍历列表。但缓存是在运行时按 MethodParameter 对象做 key 的——如果你在自定义解析器里依赖请求内容来动态决定 supportsParameter(),缓存会把它钉死成第一次的判断结果。

4.2.3 默认解析器清单与顺序

RequestMappingHandlerAdapter.getDefaultArgumentResolvers() 定义了默认顺序(7.0.9 源码,节选前段):

顺序解析器负责的参数
1RequestParamMethodArgumentResolver(false)@RequestParam、简单类型无注解参数
2RequestParamMapMethodArgumentResolver@RequestParam Map<String,String>
3PathVariableMethodArgumentResolver@PathVariable(单值)
4PathVariableMapMethodArgumentResolver@PathVariable Map<String,String>
5MatrixVariableMethodArgumentResolver@MatrixVariable
6ServletModelAttributeMethodProcessor(false)@ModelAttribute、非简单类型无注解参数
7RequestResponseBodyMethodProcessor@RequestBody
8RequestPartMethodArgumentResolver@RequestPart(多部件)
9RequestHeaderMethodArgumentResolver@RequestHeader
10ServletCookieValueMethodArgumentResolver@CookieValue
…(@SessionAttribute、@RequestAttribute、ServletRequest、ServletResponse 等)类型驱动
末PrincipalMethodArgumentResolverjava.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
}

选型规则可以归纳成三条:

  1. 看 Content-Type:请求头没有 Content-Type 时按 application/octet-stream 处理(noContentType = true),这会导致大多数 JSON 转换器 canRead 为假,最终 415。
  2. 按顺序试:this.messageConverters 是有序列表,第一个 canRead(目标类型, contentType) 为真的转换器被选中。canRead 要同时匹配媒体类型和目标 Java 类型——例如 StringHttpMessageConverter 只认 String,ByteArrayHttpMessageConverter 只认 byte[]。
  3. 失败即 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")。现代做法有两种:

  1. 编译期保留参数名:javac -parameters(Maven 下 maven-compiler-plugin 的 <parameters>true</parameters>,spring-boot-starter-parent 已默认开启)。
  2. 本地变量表调试信息:-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 过滤器、拦截器与异常解析 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计