likes
comments
collection
share

SpringMVC流程分析(九):从源码解释@ReqeustBody参数无法绑定的问题

作者站长头像
站长
· 阅读数 24

本系列文章皆在分析SpringMVC的核心组件和工作原理,让你从SpringMVC浩如烟海的代码中跳出来,以一种全局的视角来重新审视SpringMVC的工作原理。

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜


前言

而本文笔者主要想通过工作中遇到的一个小问题为例,来讲一讲在工作中我们该如何利用所学的源码知识来快速定位发生地

问题复现

在工作中笔者定义了这样一个控制层接口:

@RestController
@ReqeustMapping("/u")
public class UserController {
   
   @PostMapping("/add-user")
   public CommonResult<User> getUser(@RequestBody UserInfo userInfo,
                                      String uid ) {
      // 省略其他逻辑                     
      retrun CommonResut.success(userInfo,uid)
 }
}

其中UserInfo对象中包括两个属性:

  1. Integer : userId: 用以作用用户的唯一表示信息
  2. String : userName: 用户昵称信息

当前端发送请求时,笔者最开始提供的Json格式信息如下:

{
   "UserInfo" : {
       "userId":  123,
       "userName": "zhangSan"
    },
    "uid" : "123"
}

如果熟悉@RequestBody注解和SpringMVC框架使用的小伙伴一眼就能看出上述代码中存在的问题。事实上,针对上述代码中主要存在问题,有如下的修改意见:

  1. Post请求下,如果要接受前端传递uid信息,此时对于getUser方法应该在其参数uid前,加上一个@ReqeustParam注解,从而获取到请求url的参数信息。
  2. <1>的基础上,参数传递过程中应该其请求体中的uid信息去掉,并改为如下的写法。
{
   "userId":  123,
   "userName": "zhangSan"
}

当程序按照如上的修改意见修改后,才能确保前端传递的参数信息可以自动封装给getUser参数列表中的对象中。

此时我的疑问随之而来,为什么最开始那种参数传递方式不可以? 进一步, 如果我将getUser方法参数列表改为:

@RestController
@ReqeustMapping("/u")
public class UserController {
   
   @PostMapping("/add-user")
   public CommonResult<User> getUser(@RequestBody UserInfo userInfo,
                                      @RequestBody String uid ) {
      // 省略其他逻辑                     
      retrun CommonResut.success(userInfo,uid)
 }
}

的形式,那此时前端传递的内容可以封装到userInfo对象和uid对象中吗?

可能在平常的开发中,我们就是按部就班的按照既定的程序开发, 不曾想过这样的问题。即使遇到了后端Controller使用@RequestBody接收参数信息,但参数无法进行自动封装的问题,也只是询问一下百度,按照其他人博客的内容一同折腾配置后,结果无非两种。一种是成功解决,一种则是无法解决,然后在不断的翻阅其他博客,直到问题解决。

虽然最后问题是解决了,但真的懂了其背后的原理了吗?如果下次在遇到这类问题又该如何排查呢?

接下来,不妨看看当我们了解了框架处理流程后如何来快速定位问题。针对这个问题我们是如何定位问题和寻求解之道的。

SpringMVC中对于一个请求的处理流程

在之前文章中我们曾提到过,在SpringMVCDispatcherServlet对于一个请求处理过程可总结为如图所示的过程:

SpringMVC流程分析(九):从源码解释@ReqeustBody参数无法绑定的问题

即当一个请求到达Spring MVC的控制器方法时,一个请求的处理流程大致如下所示:

  1. Servlet容器处理请求:最初,请求由Servlet容器接收和处理。Servlet容器将HTTP请求的内容读取到一个输入流中,其中包括请求体中的数据。
  2. DispatcherServlet分派请求Servlet容器将请求传递给SpringMVCDispatcherServlet。进一步,DispatcherServlet负责将请求分派给适当的控制器方法进行处理。
  3. HandlerMapping确定控制器方法DispatcherServlet使用HandlerMapping来确定哪个控制器方法应该处理请求。HandlerMapping根据请求的URL、HTTP方法等信息找到匹配的控制器方法。
  4. 寻找适配器HandlerAdapter: 一旦确定了要使用的Controller,它会使用HandlerAdapter来适配执行请求的处理器。
  5. HttpMessageConverter执行数据解析:一旦确定了要调用的控制器方法,Spring MVC 使用HttpMessageConverter来执行请求体数据的绑定。
  6. 数据绑定和参数传递:消息转换器将请求体中的数据解析为控制器方法参数的类型,然后将参数传递给控制器方法。此时,控制器方法可以使用参数来访问请求体中的数据并进行进一步的处理。
  7. 控制器方法处理请求:控制器方法使用解析后的数据执行业务逻辑,然后返回响应。
  8. HttpMessageConverter执行响应数据转换:在控制器方法返回响应时,Spring MVC再次使用HttpMessageConverter来将Java对象转换为适当的响应格式。这是@ResponseBody注解的工作原理。
  9. 响应返回客户端DispatcherServlet将响应发送回给客户端,由Servlet容器负责将其传递给浏览器或客户端应用程序。

@RequestBody注解的内容会被Spring MVC的请求处理流程哪一步处理呢?答案其实很明显。其会在<5>HttpMessageConverter执行数据解析部分被处理。

事实上,HttpMessageConverter的作用是将请求体中的数据解析为控制器方法参数所需的Java对象。换言之,HttpMessageConverter可以根据请求头中的Content-Type和控制器方法参数的类型,选择合适的消息转换器来解析请求体中的数据。

当明白了处理@RequestBody的组件,接下来,我们的疑问就是这一组件是在何处被用到的呢? 换言之,如果我们分析@RequestBody注解的解析过程,应该从哪着手呢? 是应该从HandlerMappring入手吗?当然不是!

protected ModelAndView handleInternal(HttpServletRequest request, 
                                       HttpServletResponse response, 
                                      HandlerMethod handlerMethod) throws Exception {
    ModelAndView mav;
  
    
    // .... 省略其他无关代码
  
    // <2> 调用 HandlerMethod 方法
    mav = invokeHandlerMethod(request, response, handlerMethod);
    
    // .... 省略其他无关代码
    return mav;
}

所以如果我们要分析@ReqeustBody解析过程,我们的入口一定是RequestMappingHandlerAdapterhandleInternal方法。进一步,其内部对于@ReqeustBoyd解析流程如下所示:

SpringMVC流程分析(九):从源码解释@ReqeustBody参数无法绑定的问题 可以看到,对于@ReqeustBody注解的解析,最终解析结果是委托给objectMapper中的readValue来完成的。通过名称也可以看出,这一过程就是将请求体中传递的Json格式信息进行读取并封装为一个Java对象。具体代码细节如下:

这部分解析代码如下:

ObjectMapper#readValue

public <T> T readValue(InputStream src, JavaType valueType)
    throws IOException, JsonParseException, JsonMappingException
{
    _assertNotNull("src", src);
    return (T) _readMapAndClose(_jsonFactory.createParser(src), valueType);
} 

总结来看readValue无非完成两个逻辑:

  1. 创建JSON解析器:使用_jsonFactory(这是ObjectMapper内部的Json工厂)创建一个JSON解析器(JsonParser)。这个解析器将从提供的Reader中读取数据即Post的请求体。
  2. 反序列化过程_readMapAndClose方法开始解析输入的Json数据。在这个过程中,它会执行与之前描述的readValue方法相似的操作,包括使用Json解析器解析JSON数据,将数据映射到目标Java类的实例上,并执行必要的类型转换。

(注:这一过程执行完会关闭数据流,也就是说只能从Post请求体中的内容只能被读取一次!)

总结

至此,其实我们之前疑问已经有了答案。为什么

{
   "UserInfo" : {
       "userId":  123,
       "userName": "zhangSan"
    },
    "uid" : "123"
}

无法封装为一个UserInfo对象?答案很简单,当后端Controller中方法使用了@ReqeustBody后,其会解析请求体中Json格式的内容解析,然后封装为一个Java对象。而这一过程本质都是委托给JackSon来做的。

事实上,确保Json数据可以封装为一个Java对象的的前提就是。Json数据与Java类的匹配。换言之,Json数据中的属性名称必须与目标Java类中的字段或属性名称匹配。虽然Jackson可以通过注解和配置来自定义映射规则,但默认情况下,它会使用属性名称来匹配。

(注:SpringBoot默认是使用Jackson作为Json数据格式处理的类库)

此外,SpringMVC框架只能解析一次请求体中的内容,一旦请求体的内容被绑定到一个方法参数上,该方法中其他参数就无法再次使用@RequestBody注解来解析同一个请求体的内容。

本文以笔者工作中使用@RequestBody注解遇到的一个小问题为例,利用SpringMVC对于一个处理的流程,逐步刨根问底一步步揭秘@RequestBody解析的全过程。希望本次问题排查过程对你日后快速定位问题有所帮助!