?【并發(fā)技術系列】「Web請求讀取系列」如何構建一個可重復讀取的Request的流機制

前提背景

項目中需要記錄用戶的請求參數(shù)便于后面查找問題,對于這種需求一般可以通過Spring中的攔截器或者是使Servlet中的過濾器來實現(xiàn)。這里我選擇使用過濾器來實現(xiàn),就是添加一個過濾器,然后在過濾器中獲取到Request對象,將Reques中的信息記錄到日志中。

問題介紹

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

有時候我們的請求是post,但我們又要對參數(shù)簽名,這個時候我們需要獲取到body的信息,但是當我們使用HttpServletRequest的getReader()和getInputStream()獲取參數(shù)后,后面不管是框架還是自己想再次獲取body已經沒辦法獲取。當然也有一些其他的場景,可能需要多次獲取的情況。

可能拋出類似以下的異常

java.lang.IllegalStateException: getReader() has already been called for this request

因此,針對這問題,給出一下解決方案:

定義過濾器解決

使用過濾器很快我實現(xiàn)了統(tǒng)一記錄請求參數(shù)的的功能,整個代碼實現(xiàn)如下:

@Slf4j
@Component
public 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("請求參數(shù):{}", JSON.toJSONString(parameterMap));
        filterChain.doFilter(request,response);
    }
}

上面的實現(xiàn)方式對于GET請求沒有問題,可以很好的記錄前端提交過來的參數(shù)。對于POST請求就沒那么簡單了。根據POST請求中Content-Type類型我們常用的有下面幾種:

  • application/x-www-form-urlencoded:這種方式是最常見的方式,瀏覽器原生的form表單就是這種方式提交。
  • application/json:這種方式也算是一種常見的方式,當我們在提交一個復雜的對象時往往采用這種方式。
  • multipart/form-data:這種方式通常在使用表單上傳文件時會用。

注意:上面三種常見的POST方式我實現(xiàn)的過濾器有一種是無法記錄到的,當Content-Type為application/json時,通過調用Request對象中getParameter相關方法是無法獲取到請求參數(shù)的。

application/json解決方案及問題

想要該形式的請求參數(shù)能被打印,我們可以通過讀取Request中流的方式來獲取請求JSON請求參數(shù),現(xiàn)在修改代碼如下:

@Slf4j
@Component
public 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("請求參數(shù):{}",JSON.toJSONString(parameterMap));
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        IOUtils.copy(request.getInputStream(),out);
        log.info("請求體:{}", out.toString(request.getCharacterEncoding()));
        filterChain.doFilter(request,response);
    }
}

上面的代碼中我通過獲取Request中的流來獲取到請求提交到服務器中的JSON數(shù)據,最后在日志中能打印出客戶端提交過來的JSON數(shù)據。但是最后接口的返回并沒有成功,而且在Controller中也無法獲取到請求參數(shù),最后程序給出的錯誤提示關鍵信息為:Required request body is missing。

之所以會出現(xiàn)異常是因為Request中的流只能讀取一次,我們在過濾器中讀取之后如果后面有再次讀取流的操作就會導致服務異常,簡單的說就是Request中獲取的流不支持重復讀取。

所以這種方案Pass

擴展HttpServletRequest

HttpServletRequestWrapper

通過上面的分析我們知道了問題所在,對于Request中流無法重復讀取的問題,我們要想辦法讓其支持重復讀取。

難道我們要自己去實現(xiàn)一個Request,且我們的Request中的流還支持重復讀取,想想就知道這樣做很麻煩了。

幸運的是Servlet中提供了一個HttpServletRequestWrapper類,這個類從名字就能看出它是一個Wrapper類,就是我們可以通過它將原先獲取流的方法包裝一下,讓它支持重復讀取即可

創(chuàng)建一個自定義類

繼承HttpServletRequestWrapper實現(xiàn)一個CustomHttpServletRequest并且寫一個構造函數(shù)來緩存body數(shù)據,先將RequestBody保存為一個byte數(shù)組,然后通過Servlet自帶的HttpServletRequestWrapper類覆蓋getReader()和getInputStream()方法,使流從保存的byte數(shù)組讀取。

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()
@Override
public BufferedReader getReader() throws IOException {
    ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
    return new BufferedReader(new InputStreamReader(byteArrayInputStream));
}
重寫getInputStream()
@Override
public ServletInputStream getInputStream() throws IOException {
    return new CachedBodyServletInputStream(this.cachedBody);
}

然后再Filter中將ServletRequest替換為ServletRequestWrapper。代碼如下:

實現(xiàn)ServletInputStream

創(chuàng)建一個繼承了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();
    }
}

創(chuàng)建一個Filter加入到容器中

既然要加入到容器中,可以創(chuàng)建一個Filter,然后加入配置
我們可以簡單的繼承OncePerRequestFilter然后實現(xiàn)下面方法即可。

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

然后,添加該Filter加入即可,在上面的過濾器中先調用了getParameterMap方法獲取參數(shù),然后再獲取流,如果我先getInputStream然后再調用getParameterMap會導致參數(shù)解析失敗。

例如,將過濾器中代碼調整順序為如下:

@Slf4j
@Component
public 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("請求參數(shù):{}",JSON.toJSONString(parameterMap));
        filterChain.doFilter(request,response);
    }
}

調整了getInputStream和getParameterMap這兩個方法的調用時機,最后卻會產生兩種結果,這讓我一度以為這個是個BUG。最后我從源碼中知道了為啥會有這種結果,如果我們先調用getInputStream,這將會getParameterMap時不會去解析參數(shù),以下代碼是SpringBoot中嵌入的tomcat實現(xiàn)。

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);
        }
    }
}

上面代碼從方法名字可以看出就是用來解析參數(shù)的,其中有一處關鍵的信息如下:

        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要如此實現(xiàn)呢?tomcat如此實現(xiàn)可能是有它的道理,作為Servlet容器那必須按照Servlet規(guī)范來實現(xiàn),通過查詢相關文檔還真就找到了Servlet規(guī)范中的內容,下面是Servlet3.1規(guī)范中關于參數(shù)解析的部分內容:

image

總結

為了獲取請求中的參數(shù)我們要解決的核心問題就是讓流可以重復讀取即可,同時注意先讀取流會導致getParameterMap時參數(shù)無法解析這兩點關鍵點即可。

參考資料

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 背景 項目中需要記錄用戶的請求參數(shù)便于后面查找問題,對于這種需求一般可以通過Spring中的攔截器或者是使Serv...
    一個菜鳥JAVA閱讀 3,619評論 0 4
  • 開發(fā)中我們會使用ajax來發(fā)起請求,請求方式為get時,request.getParameterMap()可以獲取...
    java螺絲釘閱讀 7,228評論 0 1
  • SpringBoot記錄HTTP請求日志 1、需求解讀 需求: 框架需要記錄每一個HTTP請求的信息,包括請求路徑...
    eaglewa閱讀 23,189評論 1 37
  • 1.Java Web程序開發(fā)基于Servlet編程,編碼到部署有四個步驟:①編寫Servlet;②打包war文件;...
    NeyoShinado閱讀 1,320評論 0 0
  • The Request Request對象封裝了來自客戶端請求的所有信息。在HTTP協(xié)議中,這個信息是在請求的HT...
    0x70e8閱讀 647評論 0 0

友情鏈接更多精彩內容