Spring MVC请求Body重复读取导致业务异常的底层机制解析

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 规范,HttpServletRequestgetInputStream()getReader() 方法只能被 调用一次。为什么?因为这两个方法返回的是一个输入流,它代表了请求体的内容。一旦从流中读取了数据,流的指针就会移动,再次调用这两个方法,流已经到达末尾,无法再读取到任何有效数据。

这个限制是 Servlet 容器(例如 Tomcat、Jetty)强制执行的。容器为了性能优化,通常会将请求体的内容读取到缓冲区中,并只允许读取一次。

2. Spring MVC 的请求处理流程

Spring MVC 是一个基于 Servlet 规范的 Web 框架,它简化了 Web 应用的开发。Spring MVC 的请求处理流程大致如下:

  1. 客户端发起 HTTP 请求。
  2. Servlet 容器接收请求,并将其封装成 HttpServletRequest 对象。
  3. DispatcherServlet 作为 Spring MVC 的前端控制器,接收到请求。
  4. HandlerMapping 根据请求路径找到对应的 Handler (通常是一个 Controller 方法)。
  5. HandlerAdapter 负责调用 Handler。在调用之前,HandlerAdapter 可能会进行参数解析、类型转换等操作。
  6. Handler 处理请求,并返回 ModelAndView 对象。
  7. ViewResolver 根据 ModelAndView 对象选择合适的 View
  8. View 渲染模型数据,生成响应内容。
  9. 响应返回给客户端。

在这个流程中,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 类,它可以实现这个功能。

ContentCachingRequestWrapperHttpServletRequestWrapper 的一个实现,它会将请求体的内容读取到内存中,并提供 getInputStream()getReader() 方法,允许我们多次读取缓存中的内容。

以下是如何使用 ContentCachingRequestWrapper 的示例:

  1. 创建 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 对象,并将包装后的请求对象传递给后续的 FilterDispatcherServlet

  2. 配置 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;
        }
    }
  3. 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来解决这个问题。在实际开发中,要根据具体场景选择合适的解决方案,避免不必要的性能开销。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注