解決HttpServletRequest 流數據不可重復讀的操作

前言

在某些業務中可能會需要多次讀取 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()));
}

以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。

推薦閱讀: