博客 / 詳情

返回

為什麼 IO 流通常只能被讀取一次

今天我們來一起探討下 為什麼 IO 流通常只能被讀取一次?

我為什麼會發出這個疑問呢?是因為我研究Web開發中的一個問題時,HTTP請求體在 Filter(過濾器)處被讀取了之後,在 Controller(控制層)就讀不到值了,使用 @RequestBody 的時候。

無論是字節流(InputStream / OutputStream)還是字符流(Reader / Writer),所有基於流的讀取操作都會維護一個 "位置指針"

  • 初始狀態下,指針指向流的起始位置(position = 0)
  • 每次調用 read() / read(byte[]) / read(char[]) 等讀取方法時,指針會向後移動對應字節數;
  • 當指針移動到流的末尾(沒有更多數據),read() 方法會返回 -1,表示流讀取完畢;
  • 指針移動後不會自動回退,也無法反向移動(除非流顯式支持重置),因此再次讀取只能得到 -1

類比IO 流的讀取過程,就像用 磁帶播放器聽磁帶 —— 磁頭(對應流的位置指針)從磁帶開頭(指針 0)開始移動,每讀一個字節 / 字符,磁頭就往後走一步;當磁頭走到磁帶末尾,再繼續播放(讀取)就只能聽到 "沙沙聲"(流返回 -1),並且磁頭不會自動回到開頭。

當然,不是所有流都只能讀一次基於內存的流(如 ByteArrayInputStream / CharArrayReader)支持重置指針,因為它們的數據源是內存中的數組(數據不會消失),可以通過 mark()reset() 方法將指針 恢復 到標記位置。

需要注意:

  • 調用 reset() 前必須先調用 mark(int readlimit)
  • 不是所有流都支持 mark() / reset(),可以通過 inputStream.markSupported() 來進行判斷。

使用 mark() 和 reset() 方法:

// 僅適用於支持mark的流
public void processWithMark(InputStream input) throws IOException {
    if (!input.markSupported()) {
        throw new IOException("Mark not supported");
    }
    
    // 標記當前位置,參數100表示最多可回退100字節
    input.mark(100);
    
    // 第一次讀取
    byte[] firstRead = new byte[50];
    input.read(firstRead);
    System.out.println("First read: " + new String(firstRead));
    
    // 重置到標記位置
    input.reset();
    
    // 第二次讀取(相同內容)
    byte[] secondRead = new byte[50];
    input.read(secondRead);
    System.out.println("Second read: " + new String(secondRead));
}

使用 包裝類 解決上文我們提到的 HTTP請求體多次讀取 的問題:

public class MyRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] body; // 緩存請求體的字節數組

    public MyRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        // 關鍵步驟:在構造時一次性讀取並存儲原始請求流
        body = StreamUtils.copyToByteArray(request.getInputStream());
    }

    // 提供一個便捷方法,用於在過濾器中獲取請求體內容(例如記錄日誌)
    // 使用時,直接調用 getBodyString() 即可
    public String getBodyString() throws UnsupportedEncodingException {
        return new String(body, this.getCharacterEncoding());
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        // 每次調用都返回一個基於緩存數據的新流
        ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public int read() {
                return bais.read();
            }

            @Override
            public boolean isFinished() {
                return bais.available() == 0;
            }

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

            @Override
            public void setReadListener(ReadListener readListener) {
                // 無需實現
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream(), this.getCharacterEncoding()));
    }
}

然後在 過濾器 處包裝請求:

@Slf4j
@Configuration
public class RequestCachingFilterConfig {

    @Bean
    public FilterRegistrationBean requestCachingFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();

        // 核心:創建過濾器,包裝請求為 ContentCachingRequestWrapper
        registrationBean.setFilter(new OncePerRequestFilter() {
            @Override
            protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                    throws ServletException, IOException {
                // 1. 僅包裝 HTTP 請求(排除 WebSocket 等)
                if (request instanceof HttpServletRequest && !(request instanceof ContentCachingRequestWrapper)) {
                    log.info("==========進入requestCachingFilter========");
                    // 2. 包裝請求(自動緩存請求體)
                    MyRequestWrapper wrappedRequest = new MyRequestWrapper(request);
                    filterChain.doFilter(wrappedRequest, response); // 傳遞包裝後的請求
                } else {
                    filterChain.doFilter(request, response); // 無需包裝,直接放行
                }
            }
        });

        // 3. 配置攔截所有請求(可根據需求調整 URL 模式)
        registrationBean.addUrlPatterns("/*");
        registrationBean.setOrder(1); // 優先級最高,確保先於其他過濾器執行
        registrationBean.setName("requestCachingFilter");
        return registrationBean;
    }
}

IO 流只能讀取一次,是 精心設計的,貼合操作系統文件 / 網絡 IO 的 "順序消費" 特性,保持和底層系統的一致性。

外在形式越簡單的東西,智慧含量越高,因為它已經不再依賴形式,必須依靠智慧。-- 煙沙九洲

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.