Spring Boot文件上传与下载全场景实践指南
引言
在企业级Web应用开发中,文件上传与下载是最常见的业务场景之一。从用户头像上传到合同文档下载,从Excel数据导入到日志文件导出,文件操作贯穿于几乎所有业务系统的生命周期。Spring Boot作为Java领域最流行的Web开发框架,其对文件上传下载的支持既保持了Spring生态的规范性,又通过自动化配置简化了开发复杂度。本文将从协议基础、环境搭建、核心实现、扩展场景到生产优化,系统性讲解Spring Boot处理文件请求的全流程,帮助开发者掌握从基础应用到高级实战的完整能力。
一、文件上传下载的基础认知
1.1 HTTP协议中的文件传输原理
文件作为二进制数据,无法直接通过普通表单提交(application/x-www-form-urlencoded)传输,必须使用multipart/form-data
格式。该格式通过以下机制实现文件传输:
- 多部分分隔 :每个表单字段(包括文件)被
boundary
分隔符分割成独立部分 - 头部元信息 :每部分包含
Content-Disposition
头(标识字段名、文件名)和Content-Type
头(文件MIME类型) - 二进制内容:文件的原始字节流紧随头部之后
示例请求包结构:
http
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"
测试文档
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.pdf"
Content-Type: application/pdf
%PDF-1.5
...(文件二进制内容)...
------WebKitFormBoundary7MA4YWxkTrZu0gW--
1.2 Spring Boot的文件处理核心组件
Spring Boot通过spring-web
模块提供文件处理支持,核心组件包括:
- MultipartResolver :负责解析HTTP请求中的multipart数据,默认实现为
StandardServletMultipartResolver
(基于Servlet 3.0+规范) - MultipartFile接口:封装上传文件的元数据(文件名、大小、MIME类型)和操作方法(获取输入流、转存文件)
- MultipartConfigElement:配置文件上传的全局参数(最大文件大小、最大请求大小、临时存储目录等)
1.3 开发环境准备
创建Spring Boot项目时需勾选Spring Web
依赖(自动包含文件处理所需组件)。对于需要更精细控制的场景(如传统MultipartResolver),可添加commons-fileupload
依赖:
xml
<!-- 基础Web依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 可选:使用Apache Commons FileUpload解析器 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
</dependency>
二、文件上传核心实现
2.1 单文件上传基础实现
2.1.1 控制器接口设计
通过@PostMapping
注解定义上传接口,使用@RequestParam
接收MultipartFile
类型参数:
java
@RestController
@RequestMapping("/file")
public class FileController {
private static final Logger log = LoggerFactory.getLogger(FileController.class);
/**
* 单文件上传接口
* @param file 上传的文件
* @param description 文件描述(普通表单字段)
* @return 上传结果
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String description) {
// 1. 校验文件是否为空
if (file.isEmpty()) {
throw new IllegalArgumentException("上传文件不能为空");
}
// 2. 获取文件元信息
String originalFilename = file.getOriginalFilename();
long size = file.getSize();
String contentType = file.getContentType();
log.info("接收文件:{},大小:{} bytes,类型:{}", originalFilename, size, contentType);
// 3. 定义存储路径(示例使用项目运行目录的upload文件夹)
String storePath = System.getProperty("user.dir") + "/upload/" + originalFilename;
File dest = new File(storePath);
try {
// 4. 转存文件(自动创建父目录)
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
file.transferTo(dest);
} catch (IOException e) {
log.error("文件保存失败", e);
throw new RuntimeException("文件保存失败");
}
// 5. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("filename", originalFilename);
result.put("size", size);
result.put("path", storePath);
return ResponseEntity.ok(result);
}
}
2.1.2 关键步骤说明
- 参数接收 :
@RequestParam("file")
对应表单中name="file"
的文件字段 - 空文件校验 :
file.isEmpty()
判断是否为有效文件(避免空请求) - 文件转存 :
transferTo()
方法将临时文件移动到目标路径(Servlet容器会在请求处理完成后删除临时文件) - 路径处理 :使用
System.getProperty("user.dir")
获取项目运行目录,确保路径跨平台兼容
2.2 多文件上传实现
多文件上传通过MultipartFile[]
或List<MultipartFile>
接收,处理逻辑与单文件类似:
java
@PostMapping("/upload/batch")
public ResponseEntity<List<Map<String, Object>>> batchUpload(
@RequestParam("files") MultipartFile[] files) {
List<Map<String, Object>> resultList = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue; // 跳过空文件(可根据业务需求调整)
}
// 复用单文件处理逻辑
Map<String, Object> result = processSingleFile(file);
resultList.add(result);
}
return ResponseEntity.ok(resultList);
}
private Map<String, Object> processSingleFile(MultipartFile file) {
// 与单文件上传中的处理逻辑一致
// ...(省略具体实现)
}
2.3 全局参数配置
通过application.properties
配置文件上传的全局限制,Spring Boot会自动装配MultipartConfigElement
:
properties
# 单个文件最大大小(默认1MB)
spring.servlet.multipart.max-file-size=50MB
# 整个请求最大大小(默认10MB)
spring.servlet.multipart.max-request-size=200MB
# 是否启用multipart解析(默认true)
spring.servlet.multipart.enabled=true
# 超过该大小的文件会写入临时目录(默认0,所有文件都写入临时目录)
spring.servlet.multipart.file-size-threshold=2MB
# 临时存储目录(默认使用Servlet容器的临时目录)
spring.servlet.multipart.location=/tmp/upload-temp
参数作用说明:
max-file-size
:防止单个文件过大导致内存溢出max-request-size
:限制整个请求的总大小,防御大文件攻击file-size-threshold
:小文件直接在内存中处理,大文件写入临时目录(平衡内存与IO)
2.4 异常处理优化
文件上传可能抛出多种异常,需通过@ControllerAdvice
全局捕获并返回友好提示:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MultipartException.class)
public ResponseEntity<Map<String, String>> handleMultipartException(MultipartException ex) {
Map<String, String> error = new HashMap<>();
if (ex instanceof MaxUploadSizeExceededException) {
error.put("code", "413");
error.put("message", "文件大小超过限制");
} else {
error.put("code", "500");
error.put("message", "文件上传失败:" + ex.getMessage());
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
Map<String, String> error = new HashMap<>();
error.put("code", "400");
error.put("message", ex.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
三、文件下载核心实现
3.1 本地文件下载基础实现
下载接口需设置正确的响应头,告知浏览器文件类型和下载方式:
java
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(
@RequestParam String filename) {
// 1. 构造文件路径(需校验文件名防止路径遍历攻击)
String safeFilename = sanitizeFilename(filename);
File file = new File(System.getProperty("user.dir") + "/upload/" + safeFilename);
// 2. 校验文件是否存在
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
// 3. 创建资源对象
Resource resource = new FileSystemResource(file);
// 4. 设置响应头
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + safeFilename + "\"")
.header(HttpHeaders.CONTENT_TYPE,
Files.probeContentType(file.toPath()))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()))
.body(resource);
}
/**
* 文件名 sanitize 方法(防御路径遍历攻击)
*/
private String sanitizeFilename(String filename) {
// 移除路径分隔符
return filename.replaceAll("[\\\\/]", "");
}
3.2 关键响应头说明
- Content-Disposition :
attachment
表示文件应被下载,filename
指定下载后的文件名(需处理编码,避免中文乱码) - Content-Type :指定文件MIME类型(
Files.probeContentType()
自动检测,或手动指定如application/octet-stream
) - Content-Length:告知浏览器文件大小,显示下载进度条
3.3 动态生成文件下载
对于需要动态生成的文件(如实时报表、临时文件),可通过InputStreamResource
直接输出流:
java
@GetMapping("/download/generate")
public ResponseEntity<Resource> downloadGeneratedFile() {
// 1. 动态生成文件内容(示例:生成CSV)
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (CSVPrinter csvPrinter = new CSVPrinter(new OutputStreamWriter(outputStream), CSVFormat.DEFAULT)) {
csvPrinter.printRecord("姓名", "年龄", "邮箱");
csvPrinter.printRecord("张三", 28, "zhangsan@example.com");
csvPrinter.printRecord("李四", 32, "lisi@example.com");
} catch (IOException e) {
throw new RuntimeException("生成CSV失败", e);
}
// 2. 封装为Resource
InputStreamResource resource = new InputStreamResource(
new ByteArrayInputStream(outputStream.toByteArray())
);
// 3. 设置响应头(注意文件名编码)
String encodedFilename = URLEncoder.encode("用户列表.csv", StandardCharsets.UTF_8);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedFilename)
.header(HttpHeaders.CONTENT_TYPE, "text/csv")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(outputStream.size()))
.body(resource);
}
文件名编码处理:
- 对于中文文件名,使用
URLEncoder.encode()
配合filename*=UTF-8''
格式(兼容现代浏览器) - 传统方式可使用
new String(filename.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)
,但推荐新标准
3.4 大文件流式下载
直接读取大文件到内存会导致OOM,需使用流式传输:
java
@GetMapping("/download/large")
public ResponseEntity<Resource> downloadLargeFile(
@RequestParam String filename) {
File file = new File(System.getProperty("user.dir") + "/upload/" + filename);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
// 使用FileSystemResource(内部使用RandomAccessFile,支持流式读取)
Resource resource = new FileSystemResource(file);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
四、常见场景与解决方案
4.1 大文件分片上传
对于超过单文件大小限制的文件(如GB级文件),需采用分片上传方案:
- 前端分片:将文件分割为多个chunk(如每片5MB),并行上传
- 服务端接收:接收每个chunk并保存到临时目录
- 合并分片:所有chunk上传完成后,按顺序合并为完整文件
服务端核心接口示例:
java
@PostMapping("/upload/chunk")
public ResponseEntity<Map<String, Object>> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("identifier") String identifier) { // 唯一标识(如文件MD5)
// 1. 构造临时存储路径(按identifier分组)
String tempDirPath = System.getProperty("user.dir") + "/upload/temp/" + identifier;
File tempDir = new File(tempDirPath);
if (!tempDir.exists()) {
tempDir.mkdirs();
}
// 2. 保存分片(文件名格式:chunkNumber_identifier)
File chunkFile = new File(tempDir, chunkNumber + "_" + identifier);
try {
chunk.transferTo(chunkFile);
} catch (IOException e) {
throw new RuntimeException("分片保存失败");
}
// 3. 检查是否所有分片已上传
File[] chunks = tempDir.listFiles((dir, name) -> name.startsWith(chunkNumber + "_"));
if (chunks != null && chunks.length == totalChunks) {
mergeChunks(tempDir, identifier);
}
return ResponseEntity.ok(Collections.singletonMap("status", "chunk_uploaded"));
}
private void mergeChunks(File tempDir, String identifier) {
String targetPath = System.getProperty("user.dir") + "/upload/" + identifier;
try (RandomAccessFile targetFile = new RandomAccessFile(targetPath, "rw")) {
// 按分片顺序合并
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(tempDir, i + "_" + identifier);
try (FileInputStream fis = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[1024 * 1024]; // 1MB缓冲区
int len;
while ((len = fis.read(buffer)) != -1) {
targetFile.write(buffer, 0, len);
}
}
// 删除临时分片
chunkFile.delete();
}
} catch (IOException e) {
throw new RuntimeException("分片合并失败", e);
}
// 删除临时目录
tempDir.delete();
}
4.2 断点续传
在分片上传基础上,通过记录已上传的分片编号实现断点续传:
- 前端上传前检查服务端已存在的分片
- 仅上传未完成的分片
- 服务端需提供
GET /upload/check
接口返回已上传的分片列表
服务端检查接口实现:
java
@GetMapping("/upload/check")
public ResponseEntity<Map<String, Object>> checkUploadStatus(
@RequestParam("identifier") String identifier) {
String tempDirPath = System.getProperty("user.dir") + "/upload/temp/" + identifier;
File tempDir = new File(tempDirPath);
Set<Integer> uploadedChunks = new HashSet<>();
if (tempDir.exists()) {
File[] chunks = tempDir.listFiles();
if (chunks != null) {
for (File chunk : chunks) {
// 文件名格式:chunkNumber_identifier
String[] parts = chunk.getName().split("_");
if (parts.length == 2) {
uploadedChunks.add(Integer.parseInt(parts[0]));
}
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("uploadedChunks", uploadedChunks);
result.put("isComplete", uploadedChunks.size() == totalChunks); // totalChunks需从前端传递或缓存获取
return ResponseEntity.ok(result);
}
前端配合逻辑:
- 上传前调用
/upload/check
接口获取已上传分片 - 仅上传未在
uploadedChunks
中的分片 - 所有分片上传完成后触发合并操作
4.3 文件类型校验与安全检测
仅依赖前端传递的Content-Type
或文件名后缀存在安全风险,需通过文件头(Magic Number)进行真实类型校验:
文件类型校验工具类:
java
public class FileTypeValidator {
// 常见文件类型Magic Number映射(部分示例)
private static final Map<String, String> MAGIC_NUMBER_MAP = new HashMap<>();
static {
MAGIC_NUMBER_MAP.put("PDF", "25504446"); // %PDF
MAGIC_NUMBER_MAP.put("ZIP", "504B0304"); // PK..
MAGIC_NUMBER_MAP.put("JPEG", "FFD8FFE0"); // ÿØÿà
MAGIC_NUMBER_MAP.put("PNG", "89504E47"); // .PNG
}
public static boolean validateFileType(MultipartFile file, Set<String> allowedTypes) {
// 校验文件名后缀
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toUpperCase();
if (!allowedTypes.contains(extension)) {
return false;
}
// 校验文件头Magic Number
try (InputStream is = file.getInputStream()) {
byte[] header = new byte[4];
int bytesRead = is.read(header);
if (bytesRead < 4) {
return false;
}
String magicNumber = bytesToHex(header);
String expectedMagic = MAGIC_NUMBER_MAP.get(extension);
return expectedMagic != null && magicNumber.startsWith(expectedMagic);
} catch (IOException e) {
throw new RuntimeException("文件类型校验失败", e);
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hex = new StringBuilder();
for (byte b : bytes) {
hex.append(String.format("%02X", b));
}
return hex.toString();
}
}
在上传接口中使用:
java
@PostMapping("/upload/secure")
public ResponseEntity<?> secureUpload(@RequestParam("file") MultipartFile file) {
Set<String> allowedTypes = Set.of("PDF", "ZIP", "JPEG", "PNG");
if (!FileTypeValidator.validateFileType(file, allowedTypes)) {
throw new IllegalArgumentException("非法文件类型");
}
// 继续上传逻辑...
}
4.4 上传进度监控
通过Commons FileUpload
的ProgressListener
实现上传进度追踪,前端通过轮询或WebSocket获取实时进度:
进度监听配置:
java
@Configuration
public class MultipartConfig {
@Bean
public CommonsMultipartResolver multipartResolver(ProgressListener progressListener) {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setProgressListener(progressListener);
resolver.setMaxUploadSize(200 * 1024 * 1024); // 200MB
return resolver;
}
@Bean
public ProgressListener progressListener() {
return new UploadProgressListener();
}
public static class UploadProgressListener implements ProgressListener {
private final ConcurrentHashMap<String, UploadProgress> progressMap = new ConcurrentHashMap<>();
@Override
public void update(long bytesRead, long contentLength, int items) {
String requestId = RequestContextHolder.currentRequestAttributes().getSessionId();
UploadProgress progress = new UploadProgress();
progress.setBytesRead(bytesRead);
progress.setTotalSize(contentLength);
progress.setProgress(contentLength == 0 ? 0 : (int) (bytesRead * 100L / contentLength));
progressMap.put(requestId, progress);
}
public UploadProgress getProgress(String requestId) {
return progressMap.getOrDefault(requestId, new UploadProgress());
}
}
@Data
public static class UploadProgress {
private long bytesRead;
private long totalSize;
private int progress;
}
}
进度查询接口:
java
@GetMapping("/upload/progress")
public ResponseEntity<UploadProgress> getUploadProgress() {
String requestId = RequestContextHolder.currentRequestAttributes().getSessionId();
UploadProgress progress = progressListener.getProgress(requestId);
return ResponseEntity.ok(progress);
}
4.5 云存储集成(以MinIO为例)
将文件存储从本地迁移至分布式对象存储,提升扩展性和可靠性:
MinIO配置与操作类:
java
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Value("${minio.bucket}")
private String bucket;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
@Bean
public MinioTemplate minioTemplate(MinioClient client) throws Exception {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
return new MinioTemplate(client, bucket);
}
}
@Service
public class MinioTemplate {
private final MinioClient client;
private final String bucket;
public MinioTemplate(MinioClient client, String bucket) {
this.client = client;
this.bucket = bucket;
}
public void upload(MultipartFile file, String objectName) throws Exception {
client.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
}
public InputStream download(String objectName) throws Exception {
return client.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
}
public String getPresignedUrl(String objectName, int expires) throws Exception {
return client.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.expiry(expires)
.method(Method.GET)
.build());
}
}
在上传接口中使用MinIO:
java
@PostMapping("/upload/minio")
public ResponseEntity<?> uploadToMinio(@RequestParam("file") MultipartFile file) {
String objectName = UUID.randomUUID() + "_" + file.getOriginalFilename();
minioTemplate.upload(file, objectName);
String downloadUrl = minioTemplate.getPresignedUrl(objectName, 3600); // 生成1小时有效下载链接
return ResponseEntity.ok(Collections.singletonMap("downloadUrl", downloadUrl));
}