今天我們來一起探討下 為什麼 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 的 "順序消費" 特性,保持和底層系統的一致性。
外在形式越簡單的東西,智慧含量越高,因為它已經不再依賴形式,必須依靠智慧。-- 煙沙九洲