Request重复读取流

背景

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

实现过程

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

@Slf4j
@Component
public class LogFilter 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
@Component
public class LogFilter 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中获取的流不支持重复读取。

HttpServletRequestWrapper

通过上面的分析我们知道了问题所在,对于Request中流无法重复读取的问题,我们要想办法让其支持重复读取。难道我们要自己去实现一个Request,且我们的Request中的流还支持重复读取,想想就知道这样做很麻烦了。幸运的是Servlet中提供了一个HttpServletRequestWrapper类,这个类从名字就能看出它是一个Wrapper类,就是我们可以通过它将原先获取流的方法包装一下,让它支持重复读取即可。下面是我自己实现的一个可重复读取流的HttpServletRequestWrapper实现。

public class RepeatReadRequest extends HttpServletRequestWrapper {
    private ByteArrayOutputStream out;
    public RepeatReadRequest(HttpServletRequest request) {
        super(request);
    }
    @Override
    public ServletInputStream getInputStream() throws IOException {
        if (out == null){
            out = new ByteArrayOutputStream();
            IOUtils.copy(super.getInputStream(),out);
        }
        return new CustomerServletInputStream(out.toByteArray());
    }
    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
    private static class CustomerServletInputStream extends ServletInputStream{
        private final ByteArrayInputStream in;
        public CustomerServletInputStream(byte[] data) {
            this.in = new ByteArrayInputStream(data);
        }
        @Override
        public boolean isFinished() {
            throw new UnsupportedOperationException();
        }
        @Override
        public boolean isReady() {
            throw new UnsupportedOperationException();
        }
        @Override
        public void setReadListener(ReadListener listener) {
            throw new UnsupportedOperationException();
        }
        @Override
        public int read() throws IOException {
            return in.read();
        }
    }
}

上面的代码很简单,就是在获取流的同时将流里面的内容缓存起来,如果再次获取流时返回一个新创建的流给你。所以在过滤器中我们只要将原始的Request对象包装一下,然后再FilterChain中使用我们包装的Request即可,修改代码如下:

@Slf4j
@Component
public class LogFilter 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));
        //使用包装Request替换原始的Request
        request = new RepeatReadRequest(request);
        //读取流中的内容
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        IOUtils.copy(request.getInputStream(),out);
        log.info("请求体:{}", out.toString(request.getCharacterEncoding()));
        
        filterChain.doFilter(request,response);
    }
}

流读取导致参数解析失败

我在上面的过滤器中先调用了getParameterMap方法获取参数,然后再获取流。这样是没毛病的。但是在我写这个之前遇见一个特别奇怪的问题,就是如果我先getInputStream然后再调用getParameterMap会导致参数解析失败。例如我将过滤器中代码调整顺序为如下:

@Slf4j
@Component
public class LogFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //使用包装Request替换原始的Request
        request = new RepeatReadRequest(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);
    }
}

现在发送请求如下:

curl -d 'userName=tom&password=123' -H 'Content-type:application/x-www-form-urlencoded' -X POST http://127.0.0.1:8080/post/form

最后日志打印结果如下:

请求体:userName=tom&password=123
请求参数:{}

而之前的日志的打印结果如下:

请求参数:{"userName":["tom"],"password":["123"]}
请求体:

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

org.apache.catalina.connector.Request.class

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的地方只有两处,分别为getInputStreamgetReader,源码如下:
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规范中关于参数解析的部分内容:


servlet中参数解析.png

总结

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

示例代码:servlet-log

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容