6
0
0

Spring Boot 文件上传:从 MultipartFile 到可访问的 URL

2026-10-20
2026-10-21

📝 笔记摘要:Spring Boot 文件上传的四段链路

文件上传看起来就是一行 transferTo,实际上是四段各管一件事的流程:容器接住请求、落进临时盘、业务代码把文件搬走、最后把磁盘目录映射成 URL。每一段都有自己独立的配置项和失败点,混在一起排查就会一直找不到根因。

代码按 Spring Boot 4.1.1(Spring Framework 7.0.9 / Servlet 6.1 / Tomcat 11)写,踩的坑也按这个版本说:transferTo 的相对路径陷阱、原始文件名的路径穿越、中文文件名下载乱码、临时目录配错键名不生效。

这篇的结构
  1. 一次上传请求要穿过四段
  2. 搭一个能跑起来的环境
  3. 接收:MultipartFile 与两种参数注解
  4. 落盘:transferTo 的相对路径陷阱
  5. 文件名、目录与路径穿越
  6. 临时目录与上传大小限制
  7. 把磁盘目录变成可访问的 URL
  8. 下载:中文文件名与流式响应
  9. 这一档的边界在哪

1. 一次上传请求要穿过四段

浏览器把文件塞进 multipart/form-data 请求体发过来,文件并没有直接落到你的业务目录。中间隔着容器和业务代码两跳。

第 1 段 浏览器提交
  文件以 part 的形式随请求体上来,此刻还没落盘
第 2 段 容器收进临时盘
  超过阈值就落 spring.servlet.multipart.location,请求结束自动清理
第 3 段 业务代码搬运
  transferTo() 落到业务目录,路径和文件名在这一段定
第 4 段 对外提供访问
  addResourceHandlers 把磁盘目录映射成 URL 前缀,文件才真正能被下载

Spring Boot 文件上传的四段链路:浏览器提交 multipart 请求,容器收进临时盘,业务代码搬运到业务目录,最后映射成可访问的 URL 路径

四段各有一组配置项,任何一段没配对,报错都长得不一样:

第 1 段 没配 max-file-size,默认单文件上限只有 1MB,超了直接 413
第 2 段 临时目录不存在或没权限,启动就报 The temporary upload location [...] is not valid
第 3 段 目录用 mkdir() 建,多级路径静默失败,文件不落盘
第 4 段 映射没配,文件存进去了但访问就是 404

2. 搭一个能跑起来的环境

新建一个 Spring Boot 项目,勾上 spring-boot-starter-web 和 spring-boot-starter-thymeleaf(要渲染上传页面才需要后者),模板引擎选 Freemarker 或 Thymeleaf 都行。

IDEA 新建项目对话框里选择 Spring Boot 版本并勾选 Spring Web 与 Freemarker 模板引擎依赖项

提示:截图里的版本号是当年选 2.7.4 时的样子。2.x 已经停止维护了,现在新项目直接选 3.5.x 或 4.x,Java 基线是 17。

依赖就一个:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

前端页面用 enctype="multipart/form-data",这个属性漏了,浏览器会把文件名当普通文本发过去,后端拿到空文件:

<form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="file" />
    <button type="submit">上传</button>
</form>

3. 接收:MultipartFile 与两种参数注解

Spring 把上传的文件包成 MultipartFile。真正用得上的方法是这几个:

getOriginalFilename() 客户端传过来的原始文件名,不可信,只用来显示
getSize() 字节数,配合 max-file-size 判断
getContentType() MIME 类型,同样由客户端声明,不等于文件真实类型
getInputStream() 拿内容流,大文件走它
transferTo() 把内容搬到目标位置,搬完临时文件就没了

两个参数注解都能接文件,选哪个有区别:

@RequestParam("file") 按表单字段名绑定,文件名对不上就报 400。最常用
@RequestPart("file") 按 part 绑定,可以带 consumes 指定媒体类型,multipart 表单里还能用它直接绑定 POJO

区别在于 @RequestPart 多了一层媒体类型筛选的能力,@RequestParam 没有。普通文件上传用 @RequestParam 就够,需要按 image/* 之类分流的时候再换 @RequestPart。

@RestController
@RequestMapping("/upload")
public class UploadController {

    @PostMapping(value = "/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String upload(@RequestParam("file") MultipartFile file) {
        return "收到 " + file.getOriginalFilename();
    }
}

4. 落盘:transferTo 的相对路径陷阱

这一步是最容易出玄学故障的地方。文件传完了,接口返回成功,目标目录里却什么都没有。

根源在 MultipartFile.transferTo(File) 的实现。它内部先调 part.write(dest.getPath()),而 Servlet 规范规定,Part.write() 遇到相对路径时,是以容器的工作目录为基准解析的,不是以你的进程工作目录为基准。只有在 dest 是绝对路径、且文件还不存在时,Spring 才会额外做一次兜底拷贝把它搬到正确位置。

所以这段代码会“成功”,但文件跑到别处去了:

// 有坑:dest 是相对路径
File dest = new File("uploads", file.getOriginalFilename());
file.transferTo(dest);
// 文件实际落在
// /tmp/tomcat.xxxxxx/work/Tomcat/localhost/ROOT/uploads/xxx
// 这类容器临时目录里,请求结束就被清掉

改成绝对路径,并且用 Path 重载:

Path root = Paths.get("/data/uploads").toAbsolutePath().normalize();
Path target = root.resolve("2026/10/20").resolve(safeName);

Files.createDirectories(target.getParent());
file.transferTo(target);   // Path 重载:直接写到 target 指定的位置

提示:transferTo(Path) 从 Spring Framework 5.1 起就有了,Spring Boot 2.x 后期和 3.x、4.x 都能用。它内部直接拷贝到给定路径,没有 File 重载那层判断,用它最省心。

排查这类问题的特征很好认:报错路径里出现 /tmp/、/private/var/folders/、Tomcat/localhost,基本就是相对路径被容器解析走了。

5. 文件名、目录与路径穿越

getOriginalFilename() 里的内容是客户端给的,它可能带着目录分隔符,也可能压根是 ../../etc/passwd 这种东西。直接拼进落盘路径,等于把服务器的目录结构交给客户端决定。

处理原则有三条:

只取文件名 用 Paths.get(original).getFileName() 截掉目录部分
目录由服务端决定 按日期或业务 ID 分目录,不接受客户端传的路径
存储名可以改 用 UUID 或雪花 ID 重命名,原始名字存进数据库字段

String original = file.getOriginalFilename();
if (original == null || original.isBlank()) {
    throw new IllegalArgumentException("文件名为空");
}

// 截掉可能存在的目录部分,只留最后一段
String base = Paths.get(original).getFileName().toString();
// 去掉控制字符和路径分隔符
String clean = base.replaceAll("[\\p{Cntrl}]", "_");

// 存储名用服务端生成的,原始名只作展示
String stored = UUID.randomUUID().toString().replace("-", "");
String ext = "";
int dot = clean.lastIndexOf('.');
if (dot > 0) {
    ext = clean.substring(dot);
}

目录用 Files.createDirectories 建,不要用 File.mkdir():后者只建一级,父目录不存在时直接返回 false 而且不抛异常,文件自然也落不下去。createDirectories 会把整条路径逐级建出来,已经存在也不报错。

6. 临时目录与上传大小限制

这组配置的键名很容易记错,尤其是临时目录那一项:

spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 20MB
      max-request-size: 25MB
      file-size-threshold: 2MB
      location: /data/tmp/spring-multipart

提示:临时目录的配置项是 spring.servlet.multipart.location,没有 local 这个键。写错了 Spring Boot 不报错,这一段配置被静默忽略,临时目录照旧用系统临时目录,行为上完全看不出来。

各项的含义和默认值:

max-file-size 单文件上限,默认 1MB。不配的话传个 5MB 就 413
max-request-size 整个 multipart 请求上限,默认 10MB。多文件上传时它才是先撞墙的那个
file-size-threshold 超过这个大小才落临时盘,默认 0B。调小一点能让小文件走内存,省一次磁盘 IO
location 临时目录。不配就用系统临时目录,Linux 下是 /tmp,Windows 下是用户目录下的 Temp

临时目录有两个硬要求:目录得存在,运行应用的用户得对它有读写权限。权限不够时会在启动阶段抛 The temporary upload location [...] is not valid,或者上传时抛 FileNotFoundException (Permission denied)。

正常流程下 Spring 会在请求结束后自动清掉临时文件。异常中断的处理链路时(比如没 catch 住异常),临时文件会残留,磁盘慢慢被撑满。所以临时目录要挂在能监控容量的盘上,别丢进系统盘就不管了。

提示:上传大小限制不止 Spring 这一层。前面挂了 Nginx 的话,它默认 client_max_body_size 只有 1m,请求在 Nginx 就被挡了,压根到不了应用。改 Spring 的配置没反应时,先看代理层。

7. 把磁盘目录变成可访问的 URL

Spring Boot 打成的是 jar 包,static 目录里的东西没法像传统 war 包那样直接访问。要让磁盘上的文件能通过 HTTP 取到,得把目录映射成 URL 前缀。

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${file.upload-dir}")
    private String uploadDir;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // toUri() 生成 file:///D:/data/uploads/ 这种带三斜杠的绝对 URI
        String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString();
        registry.addResourceHandler("/uploadfile/**")
                .addResourceLocations(location);
    }
}

手拼 "file:" + path 是个常见的写法错误。Windows 上路径是 D:/data/uploads,拼出来是 file:D:/data/uploads,只有两个斜杠,浏览器按相对 URI 解析,访问不到。交给 toUri() 生成才稳。

file:
  upload-dir: D:/data/uploads

映射好之后,文件就能通过 /uploadfile/ 前缀访问:

浏览器访问 localhost:8090/uploadfile/image/ 下的图片文件,映射后的磁盘目录可以通过 HTTP 路径直接取到

注意:资源映射是静态资源,不走任何鉴权。配了之后这个前缀下所有文件都是公开的,谁拿到 URL 谁能下。带用户隔离的附件目录不要用静态映射,用 Controller 自己控权限。

8. 下载:中文文件名与流式响应

下载接口别用 getBytes(),那会把整个文件读进内存,一个几百 MB 的文件就能把堆打满。走流:

@GetMapping("/download")
public ResponseEntity<StreamingResponseBody> download(@RequestParam String name) throws IOException {
    Path root = Paths.get(uploadDir).toAbsolutePath().normalize();
    Path file = root.resolve(name).normalize();
    // 防目录穿越:目标必须还在 root 里面
    if (!file.startsWith(root) || !Files.exists(file)) {
        return ResponseEntity.notFound().build();
    }

    String filename = file.getFileName().toString();
    ContentDisposition disposition = ContentDisposition.attachment()
            .filename(filename, StandardCharsets.UTF_8)
            .build();

    StreamingResponseBody body = out -> {
        try (InputStream in = Files.newInputStream(file)) {
            in.transferTo(out);
        }
    };

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .contentLength(Files.size(file))
            .body(body);
}

中文文件名这里有个坑:HTTP 头只允许 ASCII,中文要按 RFC 5987 编码成 filename*=UTF-8''%E4%B8%AD%E6%96%87.jpg。用 URLEncoder.encode() 编到 filename= 后面是旧做法,浏览器解出来的文件名会是乱码。用 Spring 的 ContentDisposition 工具类,两种形式它都会给。

9. 这一档的边界在哪

本机磁盘存文件是能用的最小形态,它的问题不在代码,在部署形态:

多副本不成立 文件跟着实例走,A 节点传的文件 B 节点读不到
容量跟着单机走 磁盘满了只能换盘,还得停机
没有冗余 这块盘坏了文件就没了,没有副本可退
大文件穿应用 上传下载的字节全部过一遍应用进程,带宽和连接数都是它的

文件存储的三段式演进对比:本机磁盘、自建对象存储、云托管对象存储在存储位置、流量承担者、扩容方式和多副本能力上的差异

要往上一档,代码层的替换点很清楚——把第 3 段的落盘换成一个对象存储客户端,第 4 段的静态映射换成预签名 URL。应用自己那一套 Controller 和前端表单基本不用动。

自建 MinIO 自建对象存储 数据不能出内网、成本敏感的时候用 S3 兼容的自建方案
托管 阿里云 OSS 接入 流量波动大、要可用性 SLA、没运维人力的时候直接上云
顺带一提 AList 容器部署 支持挂 S3 后端,能把对象存储再包装成一个可预览的网盘

💡 补充:文件校验别信 getContentType()。它是客户端在请求头里声明的 MIME 类型,改个后缀名就跟着变。真要拦,得做内容嗅探或者干脆只允许白名单后缀,文件名和大小都别放开。生产环境再加一层病毒扫描,扫描放在临时目录做,扫完再往业务目录搬。

🔥 一句话总结:文件上传是四段流程,容器接、临时盘放、业务代码搬、映射成 URL;四段各配各的,其中 transferTo 一定要给绝对路径,文件名一定要自己生成。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!