SpringBoot專案中HTTP請求體只能讀一次?試試這方案

小明同学的学长發表於2024-08-07

問題描述

在基於Spring開發Java專案時,可能需要重複讀取HTTP請求體中的資料,例如使用攔截器列印入參資訊等,但當我們重複呼叫getInputStream()或者getReader()時,通常會遇到類似以下的錯誤資訊:
image
大體的意思是當前request的getInputStream()已經被呼叫過了。那為什麼會出現這個問題呢?

原因分析

主要原因有兩個,一是Java自身的設計中,InputStream作為資料管道本身只支援讀取一次,如果要支援重複讀取的話就需要重新初始化;二是Servlet容器中Request的實現問題,我們以預設的Tomcat為例,可以發現在Request有兩個boolean型別的屬性,分別是usingReader和usingInputStream,當呼叫getInputStream()或getReader()時會分別檢查兩個屬性的值,並在執行後將對應的屬性設定為true,如果在檢查時變數的值已經為true了,那麼就會報出以上錯誤資訊。
image

解決方案

不太可行的方案:簡單粗暴的反射機制

涉及到變數的修改,我們首先想到的就是有沒有提供方法進行修改,不過可惜的是usingReader和usingInputStream並未提供,所以想要在使用過程中修改這兩個屬性估計只能靠反射了,在使用過程中每次呼叫後透過反射將usingReader和usingInputStream設定為false,每次根據讀取出的內容把資料流初始化回去,理論上就可以再次讀取了。

首先說反射機制本身就是透過破壞類的封裝來實現動態修改的,有點過於粗暴了,其次也是主要原因,我們只能針對我們自己實現的程式碼進行處理,框架本身如果呼叫getInputStream()和getReader()的話,我們就沒法透過這個辦法干預了,所以這個方案在給予Spring的Web專案中並不可行。

理論上可行的方案:HttpServletRequest介面

HttpServletRequest是一個介面,理論上我們只需要建立一個實現類就可以自定義getInputStream()和getReader()的行為,自然也就能解決RequestBody不能重複讀取的問題,但這個方案的問題在於HttpServletRequest有70個方法,而我們只需要修改其中兩個而已,透過這種方式去解決有點得不償失。

部分場景可行的方案:ContentCachingRequestWrapper

Spring本身提供了一個Request包裝類來處理重複讀取的問題,即ContentCachingRequestWrapper,其實現思路就是在讀取RequestBody時將記憶體快取到它內部的一個位元組流中,後續讀取可以透過呼叫getContentAsString()或getContentAsByteArray()獲取到快取下來的內容。

之所以說這個方案是部分場景可行主要是兩個方面,一是ContentCachingRequestWrapper沒有重寫getInputStream()和getReader()方法,所以框架中使用這兩個方法的地方依然獲取不到快取下來的內容,僅支援自定義的業務邏輯;第二點和第一點有所關聯,因為其沒有修改getInputStream()和getReader()方法,所以我們在使用時只能在使用RequestBody註解後使用ContentCachingRequestWrapper,否則就會出現RequestBody註解修飾的引數無法正常讀取請求體的問題,也就限定了它的使用範圍如下圖所示:
image

如果僅需要在業務程式碼後再次讀取請求體內容,那麼使用ContentCachingRequestWrapper也足以滿足需求,具體使用方法請參考下一節的說明。

目前的最佳實踐:繼承HttpServletRequestWrapper

之前我們提到實現HttpServletRequest需要實現70個方法,所以不太可能自行實現,這個方案算是進階版本,繼承HttpServletRequest的實現類,之後再自定義我們需要修改的兩個方法。

HttpServletRequest作為一個介面,肯定會有其實現去支撐它的業務功能,因為Servlet容器的選擇較多,我們也不能使用某一方提供的實現,所以選擇的範圍也就被限制到了Java EE(現在叫Jakarta EE)標準範圍內,透過檢視HttpServletRequest的實現,可以發現在標準內提供了一個包裝類:HttpServletRequestWrapper,我們的方案也是圍繞它展開。

思路簡述

  1. 自定義子類,繼承HttpServletRequestWrapper,在子類的構造方法中將RequestBody快取到自定義的屬性中。
  2. 自定義getInputStream()和getReader()的業務邏輯,不再校驗usingReader和usingInputStream,且在呼叫時讀取快取下來的內容。
  3. 自定義Filter,將預設的HttpServletRequest替換為自定義的包裝類。

程式碼展示

  1. 繼承HttpServletRequestWrapper,實現子類CustomRequestWrapper,並自定義getInputStream()和getReader()的業務邏輯
// 1.繼承HttpServletRequestWrapper
public class CustomRequestWrapper extends HttpServletRequestWrapper {

    // 2.定義final屬性,用於快取請求體內容
    private final byte[] content;

    public CustomRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        // 3.構造方法中將請求體內容快取到內部屬性中
        this.content = StreamUtils.copyToByteArray(request.getInputStream());
    }

    // 4.重新getInputStream()
    @Override
    public ServletInputStream getInputStream() {
        // 5.將快取下來的內容轉換為位元組流
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(content);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {

            }

            @Override
            public int read() {
                // 6.讀取時讀取第5步初始化的位元組流
                return byteArrayInputStream.read();
            }
        };
    }

    // 7.重寫getReader()方法,這裡複用getInputStream()的邏輯
    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
}
  1. 自定義Filter將預設的HttpServletRequest替換為自定義的CustomRequestWrapper
// 1.實現Filter介面,此處也可以選擇繼承HttpFilter
public class RequestWrapperFilter implements Filter {
    // 2. 重寫或實現doFilter方法
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 3.此處判斷是為了縮小影響範圍,本身CustomRequestWrapper只是針對HttpServletRequest,不進行判斷可能會影響其他型別的請求
        if (request instanceof HttpServletRequest) {
            // 4.將預設的HttpServletRequest轉換為自定義的CustomRequestWrapper
            CustomRequestWrapper requestWrapper = new CustomRequestWrapper((HttpServletRequest) request);
            // 5.將轉換後的request傳遞至呼叫鏈中
            chain.doFilter(requestWrapper, response);
        } else {
            chain.doFilter(request, response);
        }
    }
}
  1. 將Filter註冊到Spring容器,這一步可以透過多種方式執行,這裡採用比較傳統但比較靈活的Bean方式註冊,如果圖方便可以透過ServletComponentScan註解+ WebFilter註解的方式。
/**
 * 過濾器配置,支援第三方過濾器
 */
@Configuration
public class FilterConfigure {
    /**
     * 請求體封裝
     * @return
     */
    @Bean
    public FilterRegistrationBean<RequestWrapperFilter> filterRegistrationBean(){
        FilterRegistrationBean<RequestWrapperFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new RequestWrapperFilter());
        bean.addUrlPatterns("/*");
        return bean;
    }
}

至此我們就可以在專案中重複讀取請求體了,如果選擇使用Spring提供的ContentCachingRequestWrapper,那麼在Filter中將CustomRequestWrapper替換為ContentCachingRequestWrapper即可,不過需要注意在上一節提到的可用範圍較小的問題。

文章內的程式碼可以參考 https://gitee.com/itartisans/itartisans-framework,這是我開源的一個SpringBoot專案腳手架,我會不定期加入一些通用功能,歡迎關注。

相關文章