写点什么

🍃【Spring 实战系列】「Web 请求读取系列」如何构建一个可重复读取的 Request 的流机制

作者:浩宇天尚
  • 2021 年 12 月 22 日
  • 本文字数:7029 字

    阅读完需:约 23 分钟

🍃【Spring实战系列】「Web请求读取系列」如何构建一个可重复读取的Request的流机制

前提背景

项目中需要记录用户的请求参数便于后面查找问题,对于这种需求一般可以通过 Spring 中的拦截器或者是使 Servlet 中的过滤器来实现。这里我选择使用过滤器来实现,就是添加一个过滤器,然后在过滤器中获取到 Request 对象,将 Reques 中的信息记录到日志中。

问题介绍

在调用 request.getReader 之后重置 HttpRequest:


有时候我们的请求是 post,但我们又要对参数签名,这个时候我们需要获取到 body 的信息,但是当我们使用 HttpServletRequest 的 getReader()和 getInputStream()获取参数后,后面不管是框架还是自己想再次获取 body 已经没办法获取。当然也有一些其他的场景,可能需要多次获取的情况。

可能抛出类似以下的异常

java.lang.IllegalStateException: getReader() has already been called for this request
复制代码


因此,针对这问题,给出一下解决方案:

定义过滤器解决

使用过滤器很快我实现了统一记录请求参数的的功能,整个代码实现如下:


@Slf4j@Componentpublic class CheckDataFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {        Map<String, String[]> parameterMap = request.getParameterMap();        log.info("请求参数:{}", JSON.toJSONString(parameterMap));        filterChain.doFilter(request,response);    }}
复制代码


上面的实现方式对于 GET 请求没有问题,可以很好的记录前端提交过来的参数。对于 POST 请求就没那么简单了。根据 POST 请求中 Content-Type 类型我们常用的有下面几种:


  • application/x-www-form-urlencoded:这种方式是最常见的方式,浏览器原生的 form 表单就是这种方式提交。

  • application/json:这种方式也算是一种常见的方式,当我们在提交一个复杂的对象时往往采用这种方式。

  • multipart/form-data:这种方式通常在使用表单上传文件时会用。


注意:上面三种常见的 POST 方式我实现的过滤器有一种是无法记录到的,当 Content-Type 为 application/json 时,通过调用 Request 对象中 getParameter 相关方法是无法获取到请求参数的。

application/json 解决方案及问题

想要该形式的请求参数能被打印,我们可以通过读取 Request 中流的方式来获取请求 JSON 请求参数,现在修改代码如下:


@Slf4j@Componentpublic class CheckDataFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {        Map<String, String[]> parameterMap = request.getParameterMap();        log.info("请求参数:{}",JSON.toJSONString(parameterMap));        ByteArrayOutputStream out = new ByteArrayOutputStream();        IOUtils.copy(request.getInputStream(),out);        log.info("请求体:{}", out.toString(request.getCharacterEncoding()));        filterChain.doFilter(request,response);    }}
复制代码


上面的代码中我通过获取 Request 中的流来获取到请求提交到服务器中的 JSON 数据,最后在日志中能打印出客户端提交过来的 JSON 数据。但是最后接口的返回并没有成功,而且在 Controller 中也无法获取到请求参数,最后程序给出的错误提示关键信息为:Required request body is missing


之所以会出现异常是因为 Request 中的流只能读取一次,我们在过滤器中读取之后如果后面有再次读取流的操作就会导致服务异常,简单的说就是 Request 中获取的流不支持重复读取。


所以这种方案 Pass

扩展 HttpServletRequest

HttpServletRequestWrapper

通过上面的分析我们知道了问题所在,对于 Request 中流无法重复读取的问题,我们要想办法让其支持重复读取。


难道我们要自己去实现一个 Request,且我们的 Request 中的流还支持重复读取,想想就知道这样做很麻烦了。


幸运的是 Servlet 中提供了一个 HttpServletRequestWrapper 类,这个类从名字就能看出它是一个 Wrapper 类,就是我们可以通过它将原先获取流的方法包装一下,让它支持重复读取即可

创建一个自定义类

继承 HttpServletRequestWrapper 实现一个 CustomHttpServletRequest 并且写一个构造函数来缓存 body 数据,先将 RequestBody 保存为一个 byte 数组,然后通过 Servlet 自带的 HttpServletRequestWrapper 类覆盖 getReader()和 getInputStream()方法,使流从保存的 byte 数组读取。


public class CustomHttpServletRequest extends HttpServletRequestWrapper {    private byte[] cachedBody;    public CustomHttpServletRequest(HttpServletRequest request) throws IOException {        super(request);        InputStream is = request.getInputStream();        this.cachedBody = StreamUtils.copyToByteArray(is);    }}
复制代码
重写 getReader()
@Overridepublic BufferedReader getReader() throws IOException {    ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);    return new BufferedReader(new InputStreamReader(byteArrayInputStream));}
复制代码
重写 getInputStream()
@Overridepublic ServletInputStream getInputStream() throws IOException {    return new CachedBodyServletInputStream(this.cachedBody);}
复制代码


然后再 Filter 中将 ServletRequest 替换为 ServletRequestWrapper。代码如下:

实现 ServletInputStream

创建一个继承了 ServletInputStream 的类


public class CachedBodyServletInputStream extends ServletInputStream {    private InputStream cachedBodyInputStream;    public CachedBodyServletInputStream(byte[] cachedBody) {        this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);    }
@Override public boolean isFinished() { try { return cachedBodyInputStream.available() == 0; } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } return false; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { throw new UnsupportedOperationException(); } @Override public int read() throws IOException { return cachedBodyInputStream.read(); }}
复制代码

创建一个 Filter 加入到容器中

既然要加入到容器中,可以创建一个 Filter,然后加入配置我们可以简单的继承 OncePerRequestFilter 然后实现下面方法即可。


@Override    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {        CustomHttpServletRequest customHttpServletRequest =                new CustomHttpServletRequest(httpServletRequest);        filterChain.doFilter(customHttpServletRequest, httpServletResponse);    }
复制代码


然后,添加该 Filter 加入即可,在上面的过滤器中先调用了 getParameterMap 方法获取参数,然后再获取流,如果我先 getInputStream 然后再调用 getParameterMap 会导致参数解析失败。


例如,将过滤器中代码调整顺序为如下:


@Slf4j@Componentpublic class CheckDataFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {        //使用包装Request替换原始的Request        request = new CustomHttpServletRequest(request);        //读取流中的内容        ByteArrayOutputStream out = new ByteArrayOutputStream();        IOUtils.copy(request.getInputStream(),out);        log.info("请求体:{}", out.toString(request.getCharacterEncoding()));        Map<String, String[]> parameterMap = request.getParameterMap();        log.info("请求参数:{}",JSON.toJSONString(parameterMap));        filterChain.doFilter(request,response);    }}
复制代码


调整了 getInputStream 和 getParameterMap 这两个方法的调用时机,最后却会产生两种结果,这让我一度以为这个是个 BUG。最后我从源码中知道了为啥会有这种结果,如果我们先调用 getInputStream,这将会 getParameterMap 时不会去解析参数,以下代码是 SpringBoot 中嵌入的 tomcat 实现。


org.apache.catalina.connector.Request:


protected void parseParameters() {    parametersParsed = true;    Parameters parameters = coyoteRequest.getParameters();    boolean success = false;    try {        // Set this every time in case limit has been changed via JMX        parameters.setLimit(getConnector().getMaxParameterCount());        // getCharacterEncoding() may have been overridden to search for        // hidden form field containing request encoding        Charset charset = getCharset();        boolean useBodyEncodingForURI = connector.getUseBodyEncodingForURI();        parameters.setCharset(charset);        if (useBodyEncodingForURI) {            parameters.setQueryStringCharset(charset);        }        // Note: If !useBodyEncodingForURI, the query string encoding is        //       that set towards the start of CoyoyeAdapter.service()        parameters.handleQueryParameters();        if (usingInputStream || usingReader) {            success = true;            return;        }        String contentType = getContentType();        if (contentType == null) {            contentType = "";        }        int semicolon = contentType.indexOf(';');        if (semicolon >= 0) {            contentType = contentType.substring(0, semicolon).trim();        } else {            contentType = contentType.trim();        }        if ("multipart/form-data".equals(contentType)) {            parseParts(false);            success = true;            return;        }        if( !getConnector().isParseBodyMethod(getMethod()) ) {            success = true;            return;        }        if (!("application/x-www-form-urlencoded".equals(contentType))) {            success = true;            return;        }        int len = getContentLength();        if (len > 0) {            int maxPostSize = connector.getMaxPostSize();            if ((maxPostSize >= 0) && (len > maxPostSize)) {                Context context = getContext();                if (context != null && context.getLogger().isDebugEnabled()) {                    context.getLogger().debug(                            sm.getString("coyoteRequest.postTooLarge"));                }                checkSwallowInput();                parameters.setParseFailedReason(FailReason.POST_TOO_LARGE);                return;            }            byte[] formData = null;            if (len < CACHED_POST_LEN) {                if (postData == null) {                    postData = new byte[CACHED_POST_LEN];                }                formData = postData;            } else {                formData = new byte[len];            }            try {                if (readPostBody(formData, len) != len) {                    parameters.setParseFailedReason(FailReason.REQUEST_BODY_INCOMPLETE);                    return;                }            } catch (IOException e) {                // Client disconnect                Context context = getContext();                if (context != null && context.getLogger().isDebugEnabled()) {                    context.getLogger().debug(                            sm.getString("coyoteRequest.parseParameters"), e);                }                parameters.setParseFailedReason(FailReason.CLIENT_DISCONNECT);                return;            }            parameters.processParameters(formData, 0, len);        } else if ("chunked".equalsIgnoreCase(                coyoteRequest.getHeader("transfer-encoding"))) {            byte[] formData = null;            try {                formData = readChunkedPostBody();            } catch (IllegalStateException ise) {                // chunkedPostTooLarge error                parameters.setParseFailedReason(FailReason.POST_TOO_LARGE);                Context context = getContext();                if (context != null && context.getLogger().isDebugEnabled()) {                    context.getLogger().debug(                            sm.getString("coyoteRequest.parseParameters"),                            ise);                }                return;            } catch (IOException e) {                // Client disconnect                parameters.setParseFailedReason(FailReason.CLIENT_DISCONNECT);                Context context = getContext();                if (context != null && context.getLogger().isDebugEnabled()) {                    context.getLogger().debug(                            sm.getString("coyoteRequest.parseParameters"), e);                }                return;            }            if (formData != null) {                parameters.processParameters(formData, 0, formData.length);            }        }        success = true;    } finally {        if (!success) {            parameters.setParseFailedReason(FailReason.UNKNOWN);        }    }}
复制代码


上面代码从方法名字可以看出就是用来解析参数的,其中有一处关键的信息如下:


        if (usingInputStream || usingReader) {            success = true;            return;        }
复制代码


这个判断的意思是如果 usingInputStream 或者 usingReader 为 true,将导致解析中断直接认为已经解析成功了。这个是两个属性默认都为 false,而将它们设置为 true 的地方只有两处,分别为 getInputStream 和 getReader,源码如下:

getInputStream()
public ServletInputStream getInputStream() throws IOException {    if (usingReader) {        throw new IllegalStateException(sm.getString("coyoteRequest.getInputStream.ise"));    }    //设置usingInputStream 为true    usingInputStream = true;    if (inputStream == null) {        inputStream = new CoyoteInputStream(inputBuffer);    }    return inputStream;}
复制代码
getReader()
public BufferedReader getReader() throws IOException {    if (usingInputStream) {        throw new IllegalStateException(sm.getString("coyoteRequest.getReader.ise"));    }    if (coyoteRequest.getCharacterEncoding() == null) {        // Nothing currently set explicitly.        // Check the content        Context context = getContext();        if (context != null) {            String enc = context.getRequestCharacterEncoding();            if (enc != null) {                // Explicitly set the context default so it is visible to                // InputBuffer when creating the Reader.                setCharacterEncoding(enc);            }        }    }    //设置usingReader为true    usingReader = true;    inputBuffer.checkConverter();    if (reader == null) {        reader = new CoyoteReader(inputBuffer);    }    return reader;}
复制代码


为何在 tomcat 要如此实现呢?tomcat 如此实现可能是有它的道理,作为 Servlet 容器那必须按照 Servlet 规范来实现,通过查询相关文档还真就找到了 Servlet 规范中的内容,下面是 Servlet3.1 规范中关于参数解析的部分内容:


总结

为了获取请求中的参数我们要解决的核心问题就是让流可以重复读取即可,同时注意先读取流会导致 getParameterMap 时参数无法解析这两点关键点即可。

参考资料

  • https://www.cnblogs.com/alter888/p/8919865.html

  • https://www.iteye.com/blog/zhangbo-peipei-163-com-2022460

发布于: 7 小时前阅读数: 6
用户头像

浩宇天尚

关注

🏆 InfoQ写作平台-签约作者 🏆 2020.03.25 加入

【个人简介】酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“ 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、APM专题及微服务/分布式体系等

评论

发布
暂无评论
🍃【Spring实战系列】「Web请求读取系列」如何构建一个可重复读取的Request的流机制