Spring MVC 请求 Body 重复读取导致业务异常的底层机制解析
大家好,今天我们来深入探讨一个在 Spring MVC 开发中经常遇到,但又容易被忽视的问题:请求 Body 的重复读取导致的业务异常。这个问题看似简单,实则涉及 Servlet 规范、Spring MVC 的底层架构以及流处理等多个方面。理解其背后的机制,有助于我们编写更健壮、更高效的 Web 应用。
1. Servlet 规范与 HttpServletRequest
在深入 Spring MVC 之前,我们必须先回顾 Servlet 规范。所有 Web 框架,包括 Spring MVC,都是构建在 Servlet 规范之上的。HttpServletRequest 接口是 Servlet API 的核心之一,它封装了客户端发起的 HTTP 请求的所有信息,包括请求头、请求参数、请求路径,以及我们今天关注的请求体 (Body)。
根据 Servlet 规范,HttpServletRequest 的 getInputStream() 或 getReader() 方法只能被 调用一次。为什么?因为这两个方法返回的是一个输入流,它代表了请求体的内容。一旦从流中读取了数据,流的指针就会移动,再次调用这两个方法,流已经到达末尾,无法再读取到任何有效数据。
这个限制是 Servlet 容器(例如 Tomcat、Jetty)强制执行的。容器为了性能优化,通常会将请求体的内容读取到缓冲区中,并只允许读取一次。
2. Spring MVC 的请求处理流程
Spring MVC 是一个基于 Servlet 规范的 Web 框架,它简化了 Web 应用的开发。Spring MVC 的请求处理流程大致如下:
- 客户端发起 HTTP 请求。
- Servlet 容器接收请求,并将其封装成
HttpServletRequest对象。 DispatcherServlet作为 Spring MVC 的前端控制器,接收到请求。HandlerMapping根据请求路径找到对应的Handler(通常是一个 Controller 方法)。HandlerAdapter负责调用Handler。在调用之前,HandlerAdapter可能会进行参数解析、类型转换等操作。Handler处理请求,并返回ModelAndView对象。ViewResolver根据ModelAndView对象选择合适的View。View渲染模型数据,生成响应内容。- 响应返回给客户端。
在这个流程中,HttpServletRequest 对象在多个环节都有可能被访问,例如:
HandlerAdapter参数解析: Spring MVC 提供了多种参数解析器,例如@RequestBody注解,它会将请求体的内容绑定到方法参数上。Filter过滤器: 在请求到达DispatcherServlet之前,可能会经过多个Filter,例如用于日志记录、权限验证等。Filter也可以访问HttpServletRequest对象。- 自定义拦截器 (Interceptor): 类似于Filter, 允许在请求处理前后执行自定义的逻辑。
3. 重复读取 Body 的场景与示例
现在,让我们来看一些可能导致请求 Body 被重复读取的场景:
-
场景 1:
Filter读取 Body 后,@RequestBody再次读取假设我们有一个
Filter,用于记录请求的 Body 内容到日志中:import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import lombok.extern.slf4j.Slf4j; @Slf4j public class RequestLoggingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String body = getRequestBody(httpRequest); log.info("Request Body: {}", body); chain.doFilter(request, response); } private String getRequestBody(HttpServletRequest request) throws IOException { StringBuilder body = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { body.append(line).append(System.lineSeparator()); } } return body.toString(); } }然后,我们在 Controller 中使用
@RequestBody注解来接收请求体:import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class MyController { @PostMapping("/test") public String test(@RequestBody MyRequest request) { return "OK"; } } class MyRequest { private String name; private int age; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } }当请求到达 Controller 时,
@RequestBody尝试读取请求体,但由于Filter已经读取过一次,导致getInputStream()返回的流已经到达末尾,@RequestBody无法读取到任何数据,从而导致数据绑定失败,抛出异常。 例如:org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing -
场景 2:多个
@RequestBody参数Spring MVC 不允许在一个 Controller 方法中使用多个
@RequestBody注解。这是因为框架无法确定应该如何将请求体的内容分配给多个参数。尝试这样做会导致异常。 -
场景 3:拦截器Interceptor读取body后,controller再次读取
这个和Filter的原理类似,都是在controller之前读取了request的inputStream.
4. 解决方案:使用 ContentCachingRequestWrapper
解决请求 Body 重复读取问题的关键在于,我们需要将请求体的内容缓存起来,以便多次读取。Spring 提供了 ContentCachingRequestWrapper 类,它可以实现这个功能。
ContentCachingRequestWrapper 是 HttpServletRequestWrapper 的一个实现,它会将请求体的内容读取到内存中,并提供 getInputStream() 和 getReader() 方法,允许我们多次读取缓存中的内容。
以下是如何使用 ContentCachingRequestWrapper 的示例:
-
创建
ContentCachingFilter:import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import org.springframework.web.util.ContentCachingRequestWrapper; import java.io.IOException; public class ContentCachingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(httpRequest); chain.doFilter(wrappedRequest, response); } }这个
Filter将原始的HttpServletRequest对象包装成ContentCachingRequestWrapper对象,并将包装后的请求对象传递给后续的Filter和DispatcherServlet。 -
配置
ContentCachingFilter:在
web.xml或 Spring 配置类中配置ContentCachingFilter。确保它在其他需要读取请求体的Filter之前执行。<filter> <filter-name>contentCachingFilter</filter-name> <filter-class>com.example.ContentCachingFilter</filter-class> </filter> <filter-mapping> <filter-name>contentCachingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>或者,使用 Spring Boot 的方式:
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class FilterConfig { @Bean public FilterRegistrationBean<ContentCachingFilter> contentCachingFilter() { FilterRegistrationBean<ContentCachingFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ContentCachingFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(1); // 设置执行顺序,确保在其他 Filter 之前执行 return registrationBean; } } -
在
Filter中读取缓存的 Body:在
RequestLoggingFilter中,我们可以使用ContentCachingRequestWrapper来读取 Body:import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import org.springframework.web.util.ContentCachingRequestWrapper; import java.io.IOException; import java.nio.charset.StandardCharsets; import lombok.extern.slf4j.Slf4j; @Slf4j public class RequestLoggingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { ContentCachingRequestWrapper wrappedRequest = (ContentCachingRequestWrapper) request; String body = getRequestBody(wrappedRequest); log.info("Request Body: {}", body); chain.doFilter(request, response); } private String getRequestBody(ContentCachingRequestWrapper request) throws IOException { // 读取缓存的 Body byte[] buf = request.getContentAsByteArray(); if (buf.length > 0) { return new String(buf, StandardCharsets.UTF_8); } return ""; } }注意,我们将
HttpServletRequest强制转换为ContentCachingRequestWrapper,并使用getContentAsByteArray()方法来读取缓存的 Body。
5. 其他注意事项
- 内存占用:
ContentCachingRequestWrapper会将整个请求体的内容读取到内存中,因此需要注意内存占用。如果请求体非常大,可能会导致内存溢出。可以考虑设置一个最大缓存大小,超出部分写入磁盘。 - 性能影响: 读取请求体并缓存会带来一定的性能开销。如果不需要多次读取请求体,则没有必要使用
ContentCachingRequestWrapper。 - 字符编码: 在读取请求体时,需要指定正确的字符编码,以避免乱码问题。通常使用
UTF-8编码。
6. 替代方案:ServletInputStream 的自定义实现
另一种更高级的解决方案是自定义 ServletInputStream 的实现。这种方法允许我们更精细地控制流的处理,例如,可以实现一个“可重置”的输入流,每次读取后自动重置到流的起始位置。
但是,自定义 ServletInputStream 的实现比较复杂,需要深入理解流处理的原理,而且容易出错。因此,建议优先使用 ContentCachingRequestWrapper。
7. 总结:理解请求Body的唯一读取限制
我们深入探讨了Spring MVC请求Body重复读取导致业务异常的底层机制,理解了Servlet规范对HttpServletRequest的getInputStream()或getReader()方法的限制,并学习了如何使用ContentCachingRequestWrapper来解决这个问题。在实际开发中,要根据具体场景选择合适的解决方案,避免不必要的性能开销。