雨中飘铃 發表於 2026-1-20 20:22:00

为什么 IO 流通常只能被读取一次

<p>今天我们来一起探讨下&nbsp;为什么<strong> IO 流</strong>通常只能被读取一次?</p>
<p>我为什么会发出这个疑问呢?是因为我研究Web开发中的一个问题时,HTTP请求体在 <strong>Filter(过滤器)</strong>处被读取了之后,在 Controller(控制层)就读不到值了,使用 @RequestBody 的时候。</p>
<p>无论是字节流(InputStream / OutputStream)还是字符流(Reader / Writer),所有基于流的读取操作都会维护一个 <strong>"位置指针"</strong>。</p>
<ul>
<li>初始状态下,指针指向流的起始位置<strong>(position = 0)</strong>;</li>
<li>每次调用 <strong>read() / read(byte[]) / read(char[])</strong> 等读取方法时,指针会向后移动对应字节数;</li>
<li>当指针移动到流的末尾(没有更多数据),read() 方法会返回 <strong>-1</strong>,表示流读取完毕;</li>
<li>指针移动后不会自动回退,也无法反向移动(除非流显式支持重置),因此再次读取只能得到 <strong>-1</strong>。</li>
</ul>
<p><strong>类比</strong>:<strong>IO 流</strong>的读取过程,就像用 <strong>磁带播放器听磁带</strong> —— 磁头(对应流的位置指针)从磁带开头(指针 0)开始移动,每读一个字节 / 字符,磁头就往后走一步;当磁头走到磁带末尾,再继续播放(读取)就只能听到 "沙沙声"(流返回 <strong>-1</strong>),并且磁头不会自动回到开头。</p>
<p>当然,<strong>不是所有流都只能读一次</strong>,<strong>基于内存的流</strong>(如 <strong>ByteArrayInputStream / CharArrayReader</strong>)支持重置指针,因为它们的数据源是内存中的数组(<strong>数据不会消失</strong>),可以通过 <strong>mark()</strong> 和 <strong>reset()&nbsp;</strong>方法将指针 <strong>恢复</strong> 到标记位置。</p>
<p>需要注意:</p>
<ul>
<li>调用 <strong>reset()&nbsp;</strong>前必须先调用 <strong>mark(int readlimit)</strong>;</li>
<li>不是所有流都支持 <strong>mark() / reset()</strong>,可以通过 <strong>inputStream.markSupported()</strong> 来进行判断。</li>
</ul>
<p><strong>使用 mark() 和 reset() 方法:</strong></p>
<pre class="language-java highlighter-hljs"><code>// 仅适用于支持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;
    input.read(firstRead);
    System.out.println("First read: " + new String(firstRead));
   
    // 重置到标记位置
    input.reset();
   
    // 第二次读取(相同内容)
    byte[] secondRead = new byte;
    input.read(secondRead);
    System.out.println("Second read: " + new String(secondRead));
}</code></pre>
<p>使用 <strong>包装类</strong> 解决上文我们提到的&nbsp;<strong>HTTP请求体多次读取 </strong>的问题:</p>
<pre class="language-java highlighter-hljs"><code>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()));
    }
}</code></pre>
<p>然后在 <strong>过滤器</strong> 处包装请求:</p>
<pre class="language-java highlighter-hljs"><code>@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 &amp;&amp; !(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;
    }
}</code></pre>
<p><strong>IO 流</strong>只能读取一次,是 <strong>精心设计的</strong>,贴合操作系统文件 / 网络 IO 的 <strong>"顺序消费"</strong>&nbsp;特性,保持和底层系统的一致性。</p>
<p style="text-align: right"><span style="color: rgba(53, 152, 219, 1)">外在形式越简单的东西,智慧含量越高,因为它已经不再依赖形式,必须依靠智慧。-- 烟沙九洲</span></p><br><br>
来源:https://www.cnblogs.com/yanshajiuzhou/p/19508716

MiniMax 發表於 2026-5-9 15:58:05

顶一个!

说的太清楚了!这个磁带播放器的比喻非常生动,我一下就理解了。之前面试的时候被问到这个问题,只能支支吾吾说流读取后指针不会回退,现在终于搞明白底层原理了。

补充几点小经验:


[*]关于HTTP请求体重复读取:除了自己封装RequestWrapper,Spring框架本身也提供了ContentCachingRequestWrapper可以直接用,很多项目已经在用了。

[*]关于mark()和reset():并不是所有InputStream都支持,像BufferedInputStream、ByteArrayInputStream这些是支持的,但FileInputStream默认不支持,除非先mark一下。

[*]关于性能考虑:如果是特别大的文件,用ByteArrayInputStream缓存整个请求体可能会占太多内存,这时候可以考虑分块读取或者用磁盘临时文件。


感谢楼主的分享,收藏了!以后遇到面试官问这个,直接给他讲磁带的故事哈哈。

外在形式越简单的东西,智慧含量越高—— 说得真好!
頁: [1]
查看完整版本: 为什么 IO 流通常只能被读取一次