IO流为什么只能读取一次?从底层原理到Web实战
IO流为什么只能读取一次?从底层原理到Web实战 🌊
🌺The Begin🌺点点关注,收藏不迷路🌺 |
引言:一个让无数开发者困惑的问题
在Web开发中,你是否遇到过这样的场景:
@RestControllerpublicclassUserController{@PostMapping("/user")publicStringcreateUser(@RequestBodyUser user){// 这里收到的user为null或数据不完整!return"success";}}// 明明在过滤器中已经读取过请求体了@WebFilter("/*")publicclassLogFilterimplementsFilter{publicvoiddoFilter(ServletRequest request,...){InputStream is = request.getInputStream();String body =IOUtils.toString(is);// 读取了请求体// ... 记录日志 chain.doFilter(request, response);// 传递给Controller}}问题:为什么过滤器读取后,Controller就收不到数据了?
答案是:IO流通常只能被读取一次。本文将深入剖析这一现象背后的原理,并提供解决方案。
1. IO流的本质:顺序读取的"磁带" 📼
1.1 位置指针(Position Pointer)
所有基于流的读取操作都维护着一个位置指针:
publicabstractclassInputStream{// 抽象的位置指针概念(源码中虽不可见,但实际存在)// private long pos; // 当前读取位置publicabstractintread()throwsIOException;}读取两次后
字节1
字节2
指针→
字节3
...
读取一次后
字节1
指针→
字节2
字节3
...
初始状态
指针→
字节1
字节2
字节3
...
1.2 读取过程模拟
publicclassStreamReadSimulation{publicstaticvoidmain(String[] args)throwsIOException{byte[] data ={65,66,67,68};// ABCD// 模拟InputStreamByteArrayInputStream stream =newByteArrayInputStream(data);// 第一次读取System.out.println("第1次读取: "+ stream.read());// 65 (A)System.out.println("第2次读取: "+ stream.read());// 66 (B)System.out.println("第3次读取: "+ stream.read());// 67 (C)System.out.println("第4次读取: "+ stream.read());// 68 (D)System.out.println("第5次读取: "+ stream.read());// -1 (EOF)// 指针已到末尾,无法再读取System.out.println("第6次读取: "+ stream.read());// -1}}输出:
第1次读取: 65 第2次读取: 66 第3次读取: 67 第4次读取: 68 第5次读取: -1 第6次读取: -1 1.3 为什么设计成只能读一次?
| 数据源类型 | 为什么只能读一次 | 类比 |
|---|---|---|
| 网络流 | 数据是实时传输的,TCP缓冲区数据读取后即丢弃 | 直播流,无法回放 |
| 文件流 | 底层是操作系统文件句柄,顺序读取效率最高 | 磁带播放器 |
| 控制台流 | 用户输入是一次性的 | 一次性对话 |
2. 深入源码:InputStream的read机制 🔍
2.1 核心方法分析
// InputStream.java (JDK源码片段)publicabstractclassInputStreamimplementsCloseable{// 抽象方法,由子类实现真正的读取publicabstractintread()throwsIOException;// 批量读取,本质是循环调用read()publicintread(byte b[],int off,int len)throwsIOException{if(b ==null){thrownewNullPointerException();}elseif(off <0|| len <0|| len > b.length - off){thrownewIndexOutOfBoundsException();}elseif(len ==0){return0;}int c =read();// 调用read()读取第一个字节if(c ==-1){return-1;} b[off]=(byte)c;int i =1;try{for(; i < len ; i++){ c =read();if(c ==-1){break;} b[off + i]=(byte)c;}}catch(IOException ee){}return i;}// 跳过n个字节,指针移动但不读取publiclongskip(long n)throwsIOException{long remaining = n;// 每次跳过1个字节(简单实现,实际子类有优化)while(remaining >0){if(read()==-1){// 通过读取来跳过break;} remaining--;}return n - remaining;}}2.2 FileInputStream的实现
// FileInputStream.java (简化版)publicclassFileInputStreamextendsInputStream{// 文件描述符privatefinalFileDescriptor fd;// 本地方法,真正读取一个字节privatenativeintread0()throwsIOException;@Overridepublicintread()throwsIOException{// 调用本地方法,操作系统维护文件指针returnread0();}}底层原理:操作系统内核维护着每个打开文件的文件偏移量,每次读取后自动增加。
2.3 SocketInputStream的实现
// SocketInputStream.java (简化版)classSocketInputStreamextendsFileInputStream{@Overridepublicintread()throwsIOException{// 网络数据从TCP缓冲区读取// 读取后数据从缓冲区移除returnsuper.read();}}3. 例外情况:支持重置的流 🔄
3.1 ByteArrayInputStream支持重置
publicclassMarkResetExample{publicstaticvoidmain(String[] args)throwsIOException{byte[] data ="Hello World".getBytes();ByteArrayInputStream bais =newByteArrayInputStream(data);System.out.println("是否支持mark/reset: "+ bais.markSupported());// true// 标记当前位置 bais.mark(0);// 第一次读取byte[] first =newbyte[5]; bais.read(first);System.out.println("第一次读取: "+newString(first));// Hello// 重置到标记位置 bais.reset();// 第二次读取(相同内容)byte[] second =newbyte[5]; bais.read(second);System.out.println("第二次读取: "+newString(second));// Hello}}输出:
是否支持mark/reset: true 第一次读取: Hello 第二次读取: Hello 3.2 mark/reset原理
// ByteArrayInputStream.java (简化版)publicclassByteArrayInputStreamextendsInputStream{protectedbyte buf[];// 数据缓冲区protectedint pos;// 当前读取位置protectedint mark;// 标记位置@Overridepublicvoidmark(int readAheadLimit){ mark = pos;// 保存当前指针位置}@Overridepublicvoidreset(){ pos = mark;// 恢复指针到标记位置}@OverridepublicbooleanmarkSupported(){returntrue;}}内存数组
buf[0]
buf[1]
buf[2]
buf[3]
...
mark=1
pos=1
reset后 pos=1
3.3 常见流的支持情况
| 流类型 | 是否支持mark | 原因 |
|---|---|---|
| ByteArrayInputStream | ✅ 支持 | 数据在内存中,可重复读取 |
| BufferedInputStream | ✅ 支持 | 内部有缓冲区 |
| FileInputStream | ❌ 不支持 | 依赖操作系统文件指针 |
| SocketInputStream | ❌ 不支持 | 网络数据实时传输 |
| System.in | ❌ 不支持 | 控制台输入一次性的 |
4. 实战:Web请求体的多次读取 💻
4.1 问题重现
@WebFilter("/*")publicclassLoggingFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequest request,ServletResponse response,FilterChain chain)throwsIOException,ServletException{HttpServletRequest req =(HttpServletRequest) request;// 读取请求体用于日志String body =readBody(req.getInputStream());System.out.println("请求体: "+ body);// 传递给Controller chain.doFilter(request, response);// ❌ Controller会收不到数据}privateStringreadBody(InputStream is)throwsIOException{ByteArrayOutputStream result =newByteArrayOutputStream();byte[] buffer =newbyte[1024];int length;while((length = is.read(buffer))!=-1){ result.write(buffer,0, length);}return result.toString();}}4.2 解决方案:包装请求
publicclassCachedBodyHttpServletRequestextendsHttpServletRequestWrapper{privatefinalbyte[] cachedBody;// 缓存请求体publicCachedBodyHttpServletRequest(HttpServletRequest request)throwsIOException{super(request);// 读取并缓存请求体this.cachedBody =readBody(request.getInputStream());}privatebyte[]readBody(InputStream is)throwsIOException{ByteArrayOutputStream baos =newByteArrayOutputStream();byte[] buffer =newbyte[1024];int read;while((read = is.read(buffer))!=-1){ baos.write(buffer,0, read);}return baos.toByteArray();}@OverridepublicServletInputStreamgetInputStream()throwsIOException{// 每次调用都返回新的流,基于缓存的数据returnnewCachedBodyServletInputStream(this.cachedBody);}@OverridepublicBufferedReadergetReader()throwsIOException{returnnewBufferedReader(newInputStreamReader(getInputStream()));}}classCachedBodyServletInputStreamextendsServletInputStream{privatefinalByteArrayInputStream inputStream;publicCachedBodyServletInputStream(byte[] cachedBody){this.inputStream =newByteArrayInputStream(cachedBody);}@Overridepublicintread()throwsIOException{return inputStream.read();}@OverridepublicbooleanisFinished(){return inputStream.available()==0;}@OverridepublicbooleanisReady(){returntrue;}@OverridepublicvoidsetReadListener(ReadListener listener){// 简化实现}}4.3 过滤器中使用包装类
@WebFilter("/*")publicclassCachingFilterimplementsFilter{@OverridepublicvoiddoFilter(ServletRequest request,ServletResponse response,FilterChain chain)throwsIOException,ServletException{HttpServletRequest req =(HttpServletRequest) request;// 包装请求CachedBodyHttpServletRequest cachedRequest =newCachedBodyHttpServletRequest(req);// 可以多次读取请求体System.out.println("过滤器第1次读取: "+IOUtils.toString(cachedRequest.getInputStream()));// 再次读取(有效!)System.out.println("过滤器第2次读取: "+IOUtils.toString(cachedRequest.getInputStream()));// 传递给Controller chain.doFilter(cachedRequest, response);// ✅ Controller能正常接收数据}}4.4 Spring框架的解决方案
Spring提供了ContentCachingRequestWrapper:
@WebFilterpublicclassSpringCachingFilterextendsOncePerRequestFilter{@OverrideprotectedvoiddoFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throwsIOException,ServletException{// Spring内置的包装类ContentCachingRequestWrapper wrapper =newContentCachingRequestWrapper(request); chain.doFilter(wrapper, response);// 请求处理后读取缓存的内容(此时才能读到)byte[] body = wrapper.getContentAsByteArray();// 记录日志等}}5. 高级技巧:包装流的多种实现 🚀
5.1 实现可重复读的InputStream
publicclassRepeatableInputStreamextendsInputStream{privatefinalbyte[] data;privateint position;privateint markPosition;publicRepeatableInputStream(byte[] data){this.data = data;this.position =0;}publicRepeatableInputStream(InputStream is)throwsIOException{ByteArrayOutputStream baos =newByteArrayOutputStream();byte[] buffer =newbyte[8192];int len;while((len = is.read(buffer))!=-1){ baos.write(buffer,0, len);}this.data = baos.toByteArray();this.position =0;}@Overridepublicintread()throwsIOException{if(position >= data.length){return-1;}return data[position++]&0xFF;}@Overridepublicintread(byte[] b,int off,int len)throwsIOException{if(b ==null){thrownewNullPointerException();}if(off <0|| len <0|| len > b.length - off){thrownewIndexOutOfBoundsException();}if(position >= data.length){return-1;}int available = data.length - position;int toRead =Math.min(len, available);System.arraycopy(data, position, b, off, toRead); position += toRead;return toRead;}@Overridepubliclongskip(long n)throwsIOException{int available = data.length - position;int toSkip =(int)Math.min(n, available); position += toSkip;return toSkip;}@Overridepublicintavailable()throwsIOException{return data.length - position;}@OverridepublicbooleanmarkSupported(){returntrue;}@Overridepublicvoidmark(int readlimit){ markPosition = position;// 标记当前位置}@Overridepublicvoidreset()throwsIOException{ position = markPosition;// 重置到标记位置}}5.2 使用示例
publicclassRepeatableStreamDemo{publicstaticvoidmain(String[] args)throwsIOException{// 原始流(只能读一次)InputStream original =newFileInputStream("test.txt");// 包装成可重复读的流RepeatableInputStream repeatable =newRepeatableInputStream(original);// 可以多次读取System.out.println("第1次读取: "+IOUtils.toString(repeatable,"UTF-8")); repeatable.reset();// 重置System.out.println("第2次读取: "+IOUtils.toString(repeatable,"UTF-8"));}}6. 性能考虑与最佳实践 📊
6.1 内存 vs IO的权衡
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接读取 | 内存占用小 | 只能读一次 | 大型文件流式处理 |
| 缓存到内存 | 可多次读取 | 内存占用大 | 小请求体(<1MB) |
| 缓存到磁盘 | 可多次读取 | IO开销大 | 超大文件需重复处理 |
6.2 Web应用中的最佳实践
@ComponentpublicclassRequestBodyCacheAdvice{// 配置:只缓存小请求体privatestaticfinalint MAX_CACHE_SIZE =1024*1024;// 1MBpublicHttpServletRequestwrapIfNeeded(HttpServletRequest request){if(isSmallRequest(request)){returnnewCachedBodyHttpServletRequest(request);}return request;// 大请求不缓存,避免内存溢出}privatebooleanisSmallRequest(HttpServletRequest request){String contentLength = request.getHeader("Content-Length");if(contentLength !=null){try{returnInteger.parseInt(contentLength)<= MAX_CACHE_SIZE;}catch(NumberFormatException e){returnfalse;}}returnfalse;// 未知大小,不缓存}}6.3 性能对比
publicclassPerformanceTest{publicstaticvoidmain(String[] args)throwsIOException{byte[] data =newbyte[1024*1024];// 1MB数据newRandom().nextBytes(data);// 1. 直接读取ByteArrayInputStream bais =newByteArrayInputStream(data);long start =System.nanoTime();readFully(bais);long directTime =System.nanoTime()- start;// 2. 缓存后读取ByteArrayInputStream bais2 =newByteArrayInputStream(data);byte[] cached =readFully(bais2); start =System.nanoTime();for(int i =0; i <10; i++){ByteArrayInputStream cachedStream =newByteArrayInputStream(cached);readFully(cachedStream);}long cachedTime =System.nanoTime()- start;System.out.println("直接读取: "+ directTime /1_000_000+"ms");System.out.println("缓存后读取10次: "+ cachedTime /1_000_000+"ms");}privatestaticbyte[]readFully(InputStream is)throwsIOException{ByteArrayOutputStream baos =newByteArrayOutputStream();byte[] buffer =newbyte[8192];int len;while((len = is.read(buffer))!=-1){ baos.write(buffer,0, len);}return baos.toByteArray();}}总结:IO流读取的本质 🎯
| 概念 | 类比 | 原因 |
|---|---|---|
| 位置指针 | 磁带机的磁头 | 操作系统和网络协议栈的设计 |
| 顺序读取 | 一次性吸管 | 数据源的实时性要求 |
| mark/reset | 书签 | 仅适用于内存数据源 |
| 包装缓存 | 录像回放 | 通过内存存储实现多次读取 |
核心原则:
- 流是顺序的:设计如此,符合底层IO模型
- 消费即消失:网络流、文件流都是"一次性的"
- 内存流可重置:只有基于内存的流支持重复读取
- Web请求体只能读一次:需要多次读取时,必须缓存
金句:
“IO流就像一条河流,你无法两次踏入同一条河流。但你可以建一个水库(缓存),让河水反复利用。”
(本文为Java IO系列文章,欢迎关注更多底层原理深度解析)
🌺The End🌺点点关注,收藏不迷路🌺 |