解决HttpServletRequest 流数据不可重复读的操作

目录
  • 前言
  • ServletRequest 数据封装原理
    • Spring MVC 对不同类型数据的封装
    • 读取参数时出现的问题
      • 具体的问题可以细分成多种情况:
  • 最佳解决方案
    • tips:
  • 总结
  • 附录代码

前言

在某些业务中可能会需要多次读取 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())));

2、请求方式为 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"));

3、请求方式为 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 的源码,实现了这种方案,基本上可以处理多种通用的数据类型了。

附录代码

package com.example.seed.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.util.ContentCachingRequestWrapper;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
 * @author Fururur
 * @date 2020/5/6-14:26
 */
@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);
            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() {
    }
}
package com.example.seed;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@ServletComponentScan
@SpringBootApplication
public class SeedApplication {
    public static void main(String[] args) {
        SpringApplication.run(SeedApplication.class, args);
    }
}
@RequestMapping("/query")
public void query(HttpServletRequest request) {
    ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
    log.info("{}", new String(wrapper.getContentAsByteArray()));
}

以上为个人经验,希望能给大家一个参考,也希望大家多多支持我们。

(0)

相关推荐

  • Request的包装类HttpServletRequestWrapper的使用说明

    目录 Request的包装类HttpServletRequestWrapper使用 大致的意思是: 上述方案解决了 HttpServletRequestWrapper和HttpServletResponseWrapper使用时的坑 WrapperRequest和WrapperResponse的使用 这里涉及到的坑 坑1 坑2 解决问题 问题延伸 Request的包装类HttpServletRequestWrapper使用 在使用zuul进行鉴权的时候,我们希望从请求Request中获取输入流,解

  • 完美解决request请求流只能读取一次的问题

    解决request请求流只能读取一次的问题 实际开发碰到的问题 解决request请求流中的数据二次或多次使用问题 实际开发碰到的问题 springboot项目中,为了防止sql注入,采用Filter拦截器对所有请求流中的json数据进行校验,请求数据没问题则继续向下执行,在后边的代码中应用到请求参数值时,发现request中的json数据为空: 除上边描述的情况,尝试过两次从request中获取json数据,第二次同样是获取不到的. 解决request请求流中的数据二次或多次使用问题 继承Ht

  • 解决HttpServletRequest 流数据不可重复读的操作

    目录 前言 ServletRequest 数据封装原理 Spring MVC 对不同类型数据的封装 读取参数时出现的问题 具体的问题可以细分成多种情况: 最佳解决方案 tips: 总结 附录代码 前言 在某些业务中可能会需要多次读取 HTTP 请求中的参数,比如说前置的 API 签名校验.这个时候我们可能会在拦截器或者过滤器中实现这个逻辑,但是尝试之后就会发现,如果在拦截器中通过 getInputStream() 读取过参数后,在 Controller 中就无法重复读取了,会抛出以下几种异常:

  • 一文搞懂MySQL脏读,幻读和不可重复读

    目录 MySQL 中事务的隔离 1.READ UNCOMMITTED 2.READ COMMITTED 3.REPEATABLE READ 4.SERIALIZABLE 前置知识 1.事务相关的常用命令 2.MySQL 8 之前查询事务的隔离级别 3.MySQL 8 之后查询事务的隔离级别 4.查看连接的客户端详情 5.查询连接客户端的数量 6.设置客户端的事务隔离级别 7.新建数据库和测试数据 8.名称约定 脏读 1.脏读演示步骤1 2.脏读演示步骤2 3.脏读演示步骤3 4.不可重复读 5.

  • MySQL脏读幻读不可重复读及事务的隔离级别和MVCC、LBCC实现

    目录 前言 事务因并发出现的问题有哪些 脏读 不可重复读 幻读 不可重复读与幻读的区别 事务的四个隔离级别 InnoDB默认的隔离级别是RR Read UnCommited 读未提交 RU Read Commited 读已提交 RC Repeatable Read 可重复读 RR Serializable 串行化 undo 版本链 read view MVCC(Multi-Version Concurrent Control )多版本并发控制 可重复读实现 读已提交实现 LBCC 锁的类型 共享

  • MySQL可重复读级别能够解决幻读吗

    引言 之前在深入了解数据库理论的时候,了解到事物的不同隔离级别可能存在的问题.为了更好的理解所以在MySQL数据库中测试复现这些问题.关于脏读和不可重复读在相应的隔离级别下都很容易的复现了.但是对于幻读,我发现在可重复读的隔离级别下没有出现,当时想到难道是MySQL对幻读做了什么处理? 测试: 创建一张测试用的表dept: CREATE TABLE `dept` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(20) DEFAULT

  • Mysql数据库事务的脏读幻读及不可重复读详解

    目录 一.什么是数据库事务 二.事务的ACID原则 1. 原子性(Atomicity) 2. 一致性(Consistency) 3. 持久性(Durability) 4. 隔离性(Isolation) 三.隔离带来的问题 1. 脏读 2. 不可重复读 3.幻读 四.手动测试下事务的过程 一.什么是数据库事务 数据库事务( transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位.事务由事务开始与事务结束之间执行的全部数

  • .NET中 关于脏读 不可重复读与幻读的代码示例

    并发可能产生的三种问题 脏读 定义:A事务执行过程中B事务读取了A事务的修改,但是A事务并没有结束(提交),A事务后来可能成功也可能失败. 比喻:A修改了源代码并且并没有提交到源代码系统,A直接通过QQ将代码发给了B,A后来取消了修改. 代码示例 复制代码 代码如下: [TestMethod]         public void 脏读_测试()         {             //前置条件             using (var context = new TestEnti

  • Swift解决UITableView空数据视图问题的简单方法

    前言 UITableView在现如今的APP中已经成为必不可少的一个控件,所以今天给大家带来UITableView在Swift中是如何实现的,下面这篇文章主要给大家介绍了关于Swift解决UITableView空数据视图的相关内容,下面话不多说了,来一起看看详细的介绍吧 tableView空数据问题 一般项目中tableView若数据为空时会有一个提示示意图 为了更好的管理这种提示示意图,笔者利用extension进行了简单的拓展 解决思路 利用swift面向协议的特点,使用协议来进行设置. 设

  • Spring Boot实战解决高并发数据入库之 Redis 缓存+MySQL 批量入库问题

    目录 前言 架构设计 代码实现 测试 总结 前言 最近在做阅读类的业务,需要记录用户的PV,UV: 项目状况:前期尝试业务阶段: 特点: 快速实现(不需要做太重,满足初期推广运营即可)快速投入市场去运营 收集用户的原始数据,三要素: 谁在什么时间阅读哪篇文章 提到PV,UV脑海中首先浮现特点: 需要考虑性能(每个客户每打开一篇文章进行记录)允许数据有较小误差(少部分数据丢失) 架构设计 架构图: 时序图 记录基础数据MySQL表结构 CREATE TABLE `zh_article_count`

  • 详解Swoole TCP流数据边界问题解决方案

    目录 1. 数据发送过程 2. 什么是数据边界 2.1 代码演示 3.EOF 解决方案 3.1 open_eof_check 3.2 open_eof_split 3.3 open_eof_check 和 open_eof_split 差异 4. 固定包头 + 包体解决方案 5. 总结 6. 扩展知识 6.1 字节序 1. 数据发送过程 首先由客户端将数据发往缓冲区 (服务端并不是直接收到的), 对于客户端来说,这次的数据即是发送成功了, 对于服务端是否真正的收到他是不知道的, 然后再由服务端从

  • Mysql事务并发脏读+不可重复读+幻读详解

    目录 Mysql的事务隔离级别 脏读 不可重复读 幻读 总结 Mysql的事务隔离级别 Mysql有四种事务隔离级别,这四种隔离级别代表当存在多个事务并发冲突时,可能出现的脏读.不可重复读.幻读的问题. 脏读 大家看一下,我们有两个事务,一个是 Transaction A,一个是 Transaction B,在第一个事务里面,它首先通过一个 where id=1 的条件查询一条数据,返回 name=Ada,age=16 的这条数据.然后第二个事务呢,它同样地是去操作 id=1 的这行数据,它通过

随机推荐