解决HttpServletRequest 流数据不可重复读

在Spring MVC应用中,由于HttpServletRequest的InputStream只能读取一次,导致在拦截器和Controller中重复读取参数时会出现异常。本文详细讲解了ServletRequest的数据封装原理,特别是针对不同类型的数据如multipart/form-data、application/x-www-form-urlencoded和application/json的处理。当需要多次读取参数时,推荐使用Spring提供的ContentCachingRequestWrapper,它能将InputStream缓存并允许多次读取。通过在Filter中包装请求,可以解决重复读取参数的问题。

https://www.cnblogs.com/Sinte-Beuve/p/13260249.html

前言

在某些业务中可能会需要多次读取 HTTP 请求中的参数,比如说前置的 API 签名校验。这个时候我们可能会在拦截器或者过滤器中实现这个逻辑,但是尝试之后就会发现,如果在拦截器中通过 getInputStream() 读取过参数后,在 Controller 中就无法重复读取了,会抛出以下几种异常:

HttpMessageNotReadableException: Required request body is missing
IllegalStateException: getInputStream() can't be called after getReader()

这个时候需要我们将请求的数据缓存起来。本文会从 ServletRequest 数据封装原理开始详细讲讲如何解决这个问题。如果不想看原理的,可直接阅读 最佳解决方案

ServletRequest 数据封装原理

平时我们接受 HTTP 请求的参数时,基本是通过 SpringMVC 的包装。

  • POST form-data 参数时,直接用实体类,或者直接在 Controller 的方法上把参数填上就可以了,手动则可以通过 request.getParameter() 来获取。
  • POST json 时,会在实体类上添加 @RequestBody 参数或者直接调用 request.getInputStream() 获取流数据。

我们可以发现在获取不同数据格式的数据时调用的方法是不同的,但是阅读源码可以发现,其实底层他们的数据来源都是一样的,只是 SpringMVC 帮我们做了一下处理。下面我们就来讲讲 ServletRequest 数据封装的原理。

实际上我们通过 HTTP 传输的参数都会存在 Request 对象的 InputStream 中,这个 Request 对象也就是 ServletRequest 最终的实现,是由 tomcat 提供的。然后针对于不同的数据格式,会在不同的时刻对 InputStream 中的数据进行封装。

Spring MVC 对不同类型数据的封装

  • GET 请求的数据一般是 Query String,直接在 url 的后面,不需要特殊处理

  • 通过例如 POST、PUT 发送 multipart/form-data 格式的数据

// 源码中适当去除无关代码
// 对于这类数据,SpringMVC 在 DispatchServlet 的 doDispatch() 方法中就会进行处理。具体处理流程如下:
// org.springframework.web.servlet.DispatcherServlet.java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    boolean multipartRequestParsed = false;
    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
    processedRequest = checkMultipart(request);
    multipartRequestParsed = (processedRequest != request);
    // Determine handler for the current request.
    // other code...
}
// 1. 调用 checkMultipart(request),当前请求的数据类型是否为 multipart/form-data
protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {
    if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
		return this.multipartResolver.resolveMultipart(request);
    }
    return request;
}
//2. 如果是,调用 multipartResolver 的 resolveMultipart(request),返回一个 StandardMultipartHttpServletRequest 对象。
// org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.java
public StandardMultipartHttpServletRequest(HttpServletRequest request) throws MultipartException {
    this(request, false);
}
public StandardMultipartHttpServletRequest(HttpServletRequest request, boolean lazyParsing) throws MultipartException {
    super(request);
    if (!lazyParsing) {
        parseRequest(request);
    }
}
// 3. 在构造 StandardMultipartHttpServletRequest 对象时,会调用 parseRequest(request),将 InputStream 中是数据流进行进一步的封装。
// 不贴源码了,主要是对 form-data 数据的封装,包含字段和文件。
  • 通过例如 POST、PUT 发送 application/x-www-form-urlencoded 格式的数据
// 非 form-data 的数据,会存储在 HttpServletRequest 的 InputStream 中。
// 在第一次调用 getParameterNames() 或 getParameter() 时,
// 会调用 parseParameters() 方法对参数进行封装,从 InputStream 中读取数据,并封装到 Map 中。

//org.apache.catalina.connector.Request.java
public String getParameter(String name) {
    if (!this.parametersParsed) {
        this.parseParameters();
    }
    return this.coyoteRequest.getParameters().getParameter(name);
}
  • 通过例如 POST、PUT 发送 application/json 格式的数据
// 数据会直接会存储在 HttpServletRequest 的 InputStream 中,通过 request.getInputStream() 或 getReader() 获取。

读取参数时出现的问题

现在我们基本已经对 SpringMVC 是如何封装 HTTP 请求参数有了一定的认识。根据之前描述的,我们如果要在拦截器中和 Controller 中重复读取参数时,会出现以下异常:

HttpMessageNotReadableException: Required request body is missing
IllegalStateException: getInputStream() can't be called after getReader()

这是由于 InputStream 这个流数据的特殊性,在 Java 中读取 InputStream 数据时,内部是通过一个指针的移动来读取一个一个的字节数据的,当读完一遍后,这个指针并不会 reset,因此第二遍读的时候就会出现问题了。而之前讲了,HTTP 请求的参数也是封装在 Request 对象中的 InputStream 里,所以当第二次调用 getInputStream() 时会抛出上述异常。具体的问题可以细分成多种情况:

  1. 请求方式为 multipart/form-data,在拦截器中手动调用 request.getInputStream()
// 上文讲了在 doDispatch() 时就会进行处理,因此这里会取不到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
  1. 请求方式为 application/x-www-form-urlencoded,在拦截器中手动调用 request.getInputStream()
// 第 1 次可以取到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
// 第一次执行 getParameter() 会调用 parseParameters(),parseParameters 进一步调用 getInputStream()
// 这里就取不到值了
log.info("form-data param: {}", request.getParameter("a"));
log.info("form-data param: {}", request.getParameter("b"));
  1. 请求方式为 application/json,在拦截器中手动调用 request.getInputStream()
// 第 1 次可以取到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
// 之后再任何地方再调用 getInputStream() 都取不到值,会抛出异常

为了能够多次获取到 HTTP 请求的参数,我们需要将 InputStream 流中的数据缓存起来。

最佳解决方案

通过查阅资料,实际上 springframework 自己就有相应的 wrapper 来解决这个问题,在 org.springframework.web.util 包下有一个 ContentCachingRequestWrapper 的类。这个类的作用就是将 InputStream 缓存到 ByteArrayOutputStream 中,通过调用 ``getContentAsByteArray()` 实现流数据的可重复读取。

/**
 * {@link javax.servlet.http.HttpServletRequest} wrapper that caches all content read from
 * the {@linkplain #getInputStream() input stream} and {@linkplain #getReader() reader},
 * and allows this content to be retrieved via a {@link #getContentAsByteArray() byte array}.

 * @see ContentCachingResponseWrapper
 */

在使用上,只需要添加一个 Filter,将 HttpServletRequest 包装成 ContentCachingResponseWrapper 返回给拦截器和 Controller 就可以了。

@Slf4j
@WebFilter(urlPatterns = "/*")
public class CachingContentFilter implements Filter {
    private static final String FORM_CONTENT_TYPE = "multipart/form-data";

    @Override
    public void init(FilterConfig filterConfig) {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        String contentType = request.getContentType();
        if (request instanceof HttpServletRequest) {
            HttpServletRequest requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
            // #1
            if (contentType != null && contentType.contains(FORM_CONTENT_TYPE)) {
                chain.doFilter(request, response);
            } else {
                chain.doFilter(requestWrapper, response);
            }
            return;
        }
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

// 添加扫描 filter 注解
@ServletComponentScan
@SpringBootApplication
public class SeedApplication {
    public static void main(String[] args) {
        SpringApplication.run(SeedApplication.class, args);
    }
}

在拦截器中,获取请求参数:

// 流数据获取,比如 json
// #2
String jsonBody = IOUtils.toString(wrapper.getContentAsByteArray(), "utf-8");
// form-data 和 urlencoded 数据
String paramA = request.getParameter("paramA");
Map<String,String[]> params = request.getParameterMap();

tips:

  1. 这里需要根据 contentType 做一下区分,遇到 multipart/form-data 数据时,不需要 wrapper,会直接通过 MultipartResolver 将参数封装成 Map,当然这也可以灵活的在拦截器中判断。
  2. wrapper 在具体使用中,我们可以使用 getContentAsByteArray() 来获取数据,并通过 IOUtils 转换成 String。尽量不使用 request.getInputStream()。因为虽然经过了包装,但是 InputStream 仍然只能读一次,而参数进入 Controller 的方法前 HttpMessageConverter 的参数转换需要调用这个方法,所以把它保留就可以了。

总结

遇到这个问题的时候也参考了很多博客,有的使用了 ContentCachingRequestWrapper,也有的自己实现了一个 Wrapper。但是自己实现 Wrapper 的方案,多半是直接在 Wrapper 的构造函数中读取流数据到 byte[] 数据中去,这样在遇到 multipart/form-data 这种数据类型的时候就会出现问题了,因为包装在调用 MultipartResolver 之前执行,再次调用的时候就读不到数据了。

所以博主又自己研究了一下 Spring 的源码,实现了这种方案,基本上可以处理多种通用的数据类型了。

<think> 首先,用户的问题是关于Spring Boot在新增数据时保证数据不重复的方法。我需要基于提供的引用内容来回答,但这些引用是关于不同主题的:引用[1]讨论接口防重复请求,引用[2]讨论MongoDB连接池配置,引用[3]显示依赖和监听程序。 引用内容: - [1]: Spring Boot接口防重复请求的四种方式,包括场景如交易接口重复提交导致重复下单。 - [2]: Spring Boot配置MongoDB连接池的方法。 - [3]: 依赖项和监听程序,提到canal.client用于监听(可能与数据同步相关)。 用户的问题核心是“新增数据时保证数据不重复”,这与数据唯一性有关。引用[1]直接相关,因为它讨论防止重复请求导致的数据重复。引用[2]和[3]似乎不太相关,但用户提到“上述引用仅供参考”,并指示参考用户上一次的问题和我的回答。用户上一次的问题没有提供,所以我只能基于当前引用。 系统级指令要求: - 使用$...$用于行内数学表达式(但问题可能不需要数学)。 - 独立公式用$$...$$并单独成段(可能不适用)。 - LaTeX语法正确(确保格式正确)。 - 使用中文回答。 - 生成相关问题(在回答末尾)。 - 引用的段落末尾自然地添加引用标识,例如[^1]。 回答结构:清晰,逐步解决问题,确保真实可靠。 关键点:用户想知道如何防止新增数据时数据重复。这可能涉及数据库约束、业务逻辑检查、接口防重等。 从引用[1],它提到Spring Boot接口防重复请求的四种方式,这对防止重复提交导致的数据重复非常相关。引用[2]和[3]不直接相关,但或许可以忽略,因为用户说“请尽可能多的参考用户上一次的问题和你的回答”,但没有提供历史,所以我主要基于引用[1]。 可能的解决方案: 1. **数据库唯一约束**:在数据库层面添加唯一索引(如唯一键),确保字段组合唯一。 2. **业务逻辑检查**:在新增数据前,查询数据库检查数据是否已存在。 3. **接口防重机制**:使用令牌、幂等性等防止重复请求。 引用[1]明确了接口防重复请求的方法,所以我应该聚焦于此。 步骤回答: - 解释为什么需要防止数据重复。 - 列出常见的解决方案。 - 基于Spring Boot实现细节。 参考引用[1],它有四种方式: 1. 基于Token机制(如一次性令牌)。 2. 基于时间戳和签名。 3. 使用分布式锁。 4. 使用Redis做请求缓存。 我应该结合引用[1]的内容。 回答结构: 1. **引言**:简要描述问题背景。 2. **解决方案**:清晰列出方法。 3. **实现示例**:提供简单代码示例(可选)。 4. **生成相关问题**:在末尾。 确保添加引用标识:例如,提到引用[1]时添加[^1]。 最后,生成相关问题。 完整回答大纲: - 开头:重述问题,解释重要性。 - 主要部分:解决方案。 - 方法1:数据库唯一约束。 - 方法2:业务逻辑检查。 - 方法3:接口防重机制(基于引用[1])。 - 结束:总结建议。 - §§相关问题§§:列出2-3个相关问题。 现在,详细内容。 </think> ### Spring Boot新增数据时保证数据不重复解决方案 在Spring Boot应用中,新增数据时保证数据不重复是一个常见的业务需求。例如,在用户注册、订单创建等场景中,如果数据重复可能导致业务异常(如重复用户ID或订单号)。这通常可以通过数据库约束、业务逻辑校验和接口防重机制来实现。以下是几种有效的解决方案,基于实际开发实践总结。我将逐步解释每种方法,并提供Spring Boot中的实现示例。 #### 1. **数据库唯一约束(推荐)** 在数据库层面添加唯一约束是最可靠的方式,它能从根本上防止重复数据插入。Spring Boot支持通过JPA或MyBatis等ORM框架轻松配置。 - **实现步骤**: - 在实体类中使用`@UniqueConstraint`注解定义唯一字段。 - 例如,假设有一个`User`实体,要求`username`字段唯一。 ```java @Entity @Table(name = "users", uniqueConstraints = { @UniqueConstraint(columnNames = "username") // 定义username唯一 }) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; // 省略其他字段和getter/setter } ``` - 当插入重复数据时,数据库会抛出异常(如`DataIntegrityViolationException`),Spring Boot可以捕获并处理。 - **优点**:高性能、原子性强,适合高并发场景。 - **缺点**:需要数据库支持,约束变更可能影响表结构。 #### 2. **业务逻辑校验(前置检查)** 在新增数据前,通过查询数据库检查数据是否已存在,这是一种灵活的防重方式。 - **实现步骤**中使用Spring Data JPA的Repository接口: - 在Service层添加校验逻辑。 ```java @Service public class UserService { @Autowired private UserRepository userRepository; public User addUser(User newUser) { // 检查username是否已存在 if (userRepository.existsByUsername(newUser.getUsername())) { throw new RuntimeException("用户名已存在,无法新增"); // 自定义异常 } return userRepository.save(newUser); } } ``` - 在Controller中调用此方法。 - **优点**:业务控制灵活,可自定义错误信息。 - **缺点**:在高并发下可能因查询延迟导致重复插入(需配合锁机制)。 #### 3. **接口防重机制(请求级别)** 针对重复请求导致的数据重复问题,可以使用接口防重技术。这在引用[1]中详细讨论了四种方式,适用于防止用户多次提交相同请求的场景(如表单提交或API调用)[^1]。核心方法是生成唯一请求标识(Token),并使用缓存或锁机制验证。 - **常用方法**(基于引用[1]扩展): - **Token机制**:前端生成一次性Token提交时验证。 - **时间戳+签名**:请求添加时间戳和签名,防止重放。 - **分布式锁**:使用Redis或ZooKeeper实现锁,确保同一请求只有一个执行。 - **Redis缓存请求ID**:缓存已处理请求的ID,新请求先检查ID是否重复。 - **Spring Boot实现示例(使用Redis防重)**: - 添加依赖(如引用[3]所示,但需调整为Redis): ```xml <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> ``` - 创建一个防重注解和拦截器: ```java // 自定义防重注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface PreventDuplicate { long expire() default 5; // Token过期时间(秒) } // 拦截器实现 public class DuplicateInterceptor extends HandlerInterceptorAdapter { @Autowired private RedisTemplate<String, String> redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod method = (HandlerMethod) handler; PreventDuplicate annotation = method.getMethodAnnotation(PreventDuplicate.class); if (annotation != null) { String token = request.getHeader("X-Request-Token"); // 从Header获取Token if (token == null || redisTemplate.hasKey(token)) { response.sendError(HttpStatus.BAD_REQUEST.value(), "重复请求,请勿重复提交"); return false; } redisTemplate.opsForValue().set(token, "processed", annotation.expire(), TimeUnit.SECONDS); // 缓存Token } } return true; } } ``` - 在Controller方法上使用注解: ```java @RestController public class UserController { @PostMapping("/addUser") @PreventDuplicate(expire = 10) // 10秒内防重 public ResponseEntity<User> addUser(@RequestBody User user) { // 调用Service新增数据 return ResponseEntity.ok(userService.addUser(user)); } } ``` - **优点**:从源头防止重复请求,适用于分布式系统。 - **缺点**:增加系统复杂度,需依赖外部存储如Redis。 #### 总结建议 - **首选方案**:对于核心业务(如订单系统),组合使用数据库唯一约束和接口防重机制,以确保最大可靠性。 - **性能考虑**:在高并发场景下,优先数据库约束;如果需要灵活控制,采用业务逻辑校验加Redis锁。 - **注意事项**:测试时应模拟并发请求(如JMeter),确保防重逻辑生效。引用[1]强调,接口防重是避免重复提交的关键,尤其是在写入操作中[^1]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值