MultipartFile 输入流可重复读吗

MultipartFile 输入流可重复读吗

结论在前

每次调用 org.springframework.web.multipart.MultipartFile#getInputStream 都会返回一个全新的输入流

所以每次 MultipartFile 输入流可重复读

前言

通常而言,流是单向读取,且不可重复读的

上面指的流包括 jdk 中的 java.iojava.util.stream 都遵守这个规则

这个例子中,第二次读取同一个流出现了异常

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var inputStream = new FileInputStream("/Users/wuhunyu/Desktop/image.jpeg");

// 第一次读取
try {
var read1 = inputStream.read();
var read2 = inputStream.read();
System.out.println(read1 + " / " + read2);
} finally {
// 关闭流
inputStream.close();
}

// 第二次读取
var read3 = inputStream.read();

控制台打印如下

shell
1
2
3
4
255 / 216
Exception in thread "main" java.io.IOException: Stream Closed
at java.base/java.io.FileInputStream.read0(Native Method)
at java.base/java.io.FileInputStream.read(FileInputStream.java:231)

换成 java.util.stream 也是一样的

java
1
2
3
4
5
6
7
final var intStream = IntStream.range(1, 4);
// 第一次读取
intStream.boxed()
.forEach(System.out::println);
// 第二次读取
intStream.boxed()
.forEach(System.out::println);

控制台打印如下

shell
1
2
3
4
5
6
7
8
9
10
1
2
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)
at java.base/java.util.stream.ReferencePipeline.<init>(ReferencePipeline.java:96)
at java.base/java.util.stream.ReferencePipeline$StatelessOp.<init>(ReferencePipeline.java:800)
at java.base/java.util.stream.IntPipeline$1.<init>(IntPipeline.java:174)
at java.base/java.util.stream.IntPipeline.mapToObj(IntPipeline.java:174)
at java.base/java.util.stream.IntPipeline.boxed(IntPipeline.java:233)

探索

假设你需要开发一个日志切面,这个日志切面需要记录每次请求的 uri ,请求明细数据以及响应数据

这里我们不探讨需求是否合理,只研究如何实现

比较常见的做法是,利用 spring 的 aop 特性,编写一个切面,来记录网络请求的各项参数

这里比较棘手的问题在于,请求体中的数据如果不事先缓存起来,那么在这个日志切面中读取一次之后,在业务层就无法再次读取请求体

原因在于请求体是 java.io.InputStream 类型,遵守单向读取,且不可重复读

为了处理这个问题,一般会缓存原始的请求体数据,每次要读取时,就从缓存中读取即可

那么,接下来,再看看这个例子

这个例子的功能很简单,把上传的文件复制了两份,分别叫做 image1.jpegimage2.jpeg,并保存在 /Users/wuhunyu/Desktop 目录下

可以看到复制过程中使用了 try-with-resources 语法,也就说, input 流使用完毕之后就会被关闭

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("/upload")
public String inputStream(
@RequestParam("file") final MultipartFile file
) {
final Consumer<String> task = fileName -> {
try (
final var output = new FileOutputStream("/Users/wuhunyu/Desktop/" + fileName);
final var input = file.getInputStream()
) {
input.transferTo(output);
} catch (IOException e) {
throw new RuntimeException(e);
}
};

task.accept("image1.jpeg");
task.accept("image2.jpeg");

return "ok";
}

MultipartFile 是 spring 中对网络文件对象的抽象, 我使用的是 tomcat 内置容器,它的实现是 org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.StandardMultipartFile

在上面的例子中, file.getInputStream() 被执行了两次,如果 org.springframework.web.multipart.MultipartFile#getInputStream 返回的是同一个流对象,那么必然会出现前两个例子中的异常

但很幸运的是,代码正常运行,且在 /Users/wuhunyu/Desktop 目录下,也像我们预期的那样多了两个文件( image1.jpegimage2.jpeg)

这就令人疑惑了,难道 org.springframework.web.multipart.MultipartFile#getInputStream 返回的不是同一个流对象吗

翻看源码,来到了 org.apache.tomcat.util.http.fileupload.disk.DiskFileItem#getInputStream

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Returns an {@link java.io.InputStream InputStream} that can be
* used to retrieve the contents of the file.
*
* @return An {@link java.io.InputStream InputStream} that can be
* used to retrieve the contents of the file.
*
* @throws IOException if an error occurs.
*/
@Override
public InputStream getInputStream()
throws IOException {
if (!isInMemory()) {
return Files.newInputStream(dfos.getFile().toPath());
}

if (cachedContent == null) {
cachedContent = dfos.getData();
}
return new ByteArrayInputStream(cachedContent);
}

别的不看,两个 return 都是创建了新的流.那就说明每次 getInputStream 时,都是一个全新的 InputStream 对象,这样就不奇怪为什么 org.springframework.web.multipart.MultipartFile#getInputStream 获取的流可以被多次使用了

那么, spring 是怎么做到的呢,莫非也是缓存?

简单 debug 之后,发现在 org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* Processes an <a href="http://www.ietf.org/rfc/rfc1867.txt">RFC 1867</a>
* compliant {@code multipart/form-data} stream.
*
* @param ctx The context for the request to be parsed.
*
* @return A list of {@code FileItem} instances parsed from the
* request, in the order that they were transmitted.
*
* @throws FileUploadException if there are problems reading/parsing
* the request or storing files.
*/
public List<FileItem> parseRequest(final RequestContext ctx)
throws FileUploadException {
final List<FileItem> items = new ArrayList<>();
boolean successful = false;
try {
final FileItemIterator iter = getItemIterator(ctx);
final FileItemFactory fileItemFactory = Objects.requireNonNull(getFileItemFactory(),
"No FileItemFactory has been set.");
final byte[] buffer = new byte[Streams.DEFAULT_BUFFER_SIZE];
while (iter.hasNext()) {
if (items.size() == fileCountMax) {
// The next item will exceed the limit.
throw new FileCountLimitExceededException(ATTACHMENT, getFileCountMax());
}
final FileItemStream item = iter.next();
// Don't use getName() here to prevent an InvalidFileNameException.
final String fileName = item.getName();
final FileItem fileItem = fileItemFactory.createItem(item.getFieldName(), item.getContentType(),
item.isFormField(), fileName);
items.add(fileItem);
try {
Streams.copy(item.openStream(), fileItem.getOutputStream(), true, buffer);
} catch (final FileUploadIOException e) {
throw (FileUploadException) e.getCause();
} catch (final IOException e) {
throw new IOFileUploadException(String.format("Processing of %s request failed. %s",
MULTIPART_FORM_DATA, e.getMessage()), e);
}
final FileItemHeaders fih = item.getHeaders();
fileItem.setHeaders(fih);
}
successful = true;
return items;
} catch (final FileUploadException e) {
throw e;
} catch (final IOException e) {
throw new FileUploadException(e.getMessage(), e);
} finally {
if (!successful) {
for (final FileItem fileItem : items) {
try {
fileItem.delete();
} catch (final Exception ignored) {
// ignored TODO perhaps add to tracker delete failure list somehow?
}
}
}
}
}

第 34 行,出现了一个流复制.其中,输入流来源于请求,输出流如下源码.创建了一个临时文件

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Returns an {@link java.io.OutputStream OutputStream} that can
* be used for storing the contents of the file.
*
* @return An {@link java.io.OutputStream OutputStream} that can be used
* for storing the contents of the file.
*
*/
@Override
public OutputStream getOutputStream() {
if (dfos == null) {
final File outputFile = getTempFile();
dfos = new DeferredFileOutputStream(sizeThreshold, outputFile);
}
return dfos;
}

并且在 org.apache.tomcat.util.http.fileupload.FileUploadBase#parseRequest 的 finally 块中,对 items 进行了清理

这样说来,spring 在处理文件上传时,会先将上传的文件写入到临时文件中,而后续开发者对 MultipartFile 对象的操作本质都是针对这个已经缓存在服务器上的临时文件,即便是多次创建新的输入流,成本也并没有网络io那么高

可为什么要这么做呢?spring 大可以告知开发者们,文件流只允许读取一次,如果有多次读取的需求,请自行实现

带着这个问题,我想看看在 go 中,它的一个热门 web 框架 gin 是怎么看待这个问题的

示例代码如下,省略了很多错误处理

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func main() {
r := gin.Default()
r.POST("/upload", func(c *gin.Context) {
formFile, _ := c.FormFile("file")
task := func(targetName string) bool {
file, err := formFile.Open()
if err != nil {
return false
}
targetFile, _ := os.OpenFile(targetName, os.O_WRONLY|os.O_CREATE, 0666)
defer func() {
_ = targetFile.Close()
}()
_, _ = io.Copy(targetFile, file)
if err := file.Close(); err != nil {
return false
}
return true
}

if !task("/Users/wuhunyu/Desktop/image1.jpeg") {
c.JSON(http.StatusBadRequest, gin.H{
"message": fmt.Sprintf("upload file to %s failed", "image1"),
})
return
}
if !task("/Users/wuhunyu/Desktop/image2.jpeg") {
c.JSON(http.StatusBadRequest, gin.H{
"message": fmt.Sprintf("upload file to %s failed", "image2"),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "upload success",
})
c.Done()
})
err := r.Run(":8080")
if err != nil {
panic(err)
}
}

代码中, formFileOpen 了两次,最后的结果也是正常,且成功复制了两个文件( image1.jpegimage2.jpeg)

也就是说, gin 的处理和 spring 是一样的,都是支持流被重复读取

至于为什么 StandardMultipartFile 被设计成可以被重复读取, ai 给我的回答如下

  1. HTTP 请求在本质上是一个原子性的、一次性的事务。服务器必须读取完整个请求的输入流,才能确认请求已经结束并且没有损坏。一旦这个底层的网络输入流被读取完毕,它就被消费掉了,无法“倒带”或重读
  2. 将文件缓存下来,最大的好处就是文件内容可以被多次、独立地访问
  3. 将文件的接收阶段处理阶段解耦,可以极大地提高系统的可靠性
  4. 框架(如 Spring MVC)和 Servlet 容器的设计目标之一就是简化开发者的工作

主要能说服我的是第一点

响应式编程

传统的HTTP请求是请求-响应式的,如果换成响应式编程呢

特性 Spring MVC (MultipartFile) Spring WebFlux (FilePart)
核心机制 先缓存,后处理 (Buffer-then-process) 真正的流式处理 (True Streaming)
调用时机 控制器方法在整个文件接收并缓存完毕后才被调用。 控制器方法在请求头到达后立即被调用,文件内容以流的形式后续提供。
资源占用 内存或磁盘占用与上传文件大小成正比。一个 1GB 的文件会占用 1GB 的临时存储。 内存占用非常小且基本恒定,与文件大小无关。只占用一小块缓冲区来处理当前的数据块。
编程模型 命令式、阻塞式。transferTo() 是一个阻塞操作,直到文件写完才返回。 响应式、非阻塞。transferTo() 返回一个 Mono,立即返回,文件写入在后台异步进行。
可扩展性 处理大量并发大文件上传时,会因临时存储耗尽或线程阻塞而遇到瓶颈。 高度可扩展。由于资源占用低且非阻塞,可以用少量线程处理大量并发上传。

响应式编程默认情况下文件流不会被完整缓存到服务器内存或磁盘后才交给代码处理,它采用了真正的流式处理

作者

wuhunyu

发布于

2025-10-10

更新于

2026-05-13

许可协议

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×