文件上传与下载
文件处理是 Web 应用的常见需求:头像上传、报表导出、文档预览。Spring MVC 通过
MultipartFile封装上传,通过Resource抽象下载。这一章覆盖单文件/多文件上传、大文件流式处理、断点续传,以及生产环境的安全和性能要点。
1. 文件上传
1.1 基础配置
yaml
# application.yml
spring:
servlet:
multipart:
enabled: true
max-file-size: 10MB # 单文件大小限制
max-request-size: 50MB # 总请求大小限制
file-size-threshold: 2KB # 超过此大小写入临时文件
location: /tmp # 临时文件目录1.2 单文件上传
java
@RestController
@RequestMapping("/api/files")
public class FileController {
@Value("${app.upload.dir:${user.home}/uploads}")
private String uploadDir;
@PostMapping("/upload")
public FileInfo upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("文件不能为空");
}
// 文件信息
String originalName = file.getOriginalFilename();
String contentType = file.getContentType();
long size = file.getSize();
// 生成唯一文件名,保留扩展名
String ext = StringUtils.getFilenameExtension(originalName);
String storedName = UUID.randomUUID() + "." + ext;
// 保存文件
Path target = Path.of(uploadDir, storedName);
file.transferTo(target.toFile());
return new FileInfo(originalName, storedName, contentType, size);
}
}1.3 多文件上传
java
@PostMapping("/upload/batch")
public List<FileInfo> uploadBatch(
@RequestParam("files") List<MultipartFile> files) {
return files.stream()
.filter(f -> !f.isEmpty())
.map(this::saveFile)
.toList();
}1.4 与实体关联
java
@RestController
@RequestMapping("/api/users/{userId}/avatar")
public class AvatarController {
@PostMapping
public User updateAvatar(
@PathVariable Long userId,
@RequestParam("file") MultipartFile file) {
// 上传文件
String storedName = saveFile(file);
// 更新用户头像字段
User user = userService.findById(userId);
user.setAvatarUrl("/api/files/" + storedName);
return userService.save(user);
}
}2. 大文件上传
2.1 流式处理(避免内存溢出)
MultipartFile.transferTo() 会将整个文件加载到内存或临时文件。大文件应使用流式写入:
java
@PostMapping("/upload/stream")
public FileInfo uploadStream(@RequestParam("file") MultipartFile file) {
String storedName = generateStoredName(file);
Path target = Path.of(uploadDir, storedName);
// 流式写入,不在内存中缓冲整个文件
try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(target)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
throw new FileUploadException("文件保存失败", e);
}
return new FileInfo(file.getOriginalFilename(), storedName,
file.getContentType(), file.getSize());
}2.2 分片上传
大文件(>100MB)建议前端分片上传:
java
@RestController
@RequestMapping("/api/files/chunk")
public class ChunkUploadController {
// 上传分片
@PostMapping
public ChunkResult uploadChunk(
@RequestParam("file") MultipartFile chunk,
@RequestParam("uploadId") String uploadId,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks) {
Path chunkDir = Path.of(uploadDir, "chunks", uploadId);
Files.createDirectories(chunkDir);
Path chunkPath = chunkDir.resolve("chunk_" + chunkNumber);
chunk.transferTo(chunkPath.toFile());
return new ChunkResult(uploadId, chunkNumber, true);
}
// 合并分片
@PostMapping("/merge")
public FileInfo mergeChunks(
@RequestParam("uploadId") String uploadId,
@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") int totalChunks) {
Path chunkDir = Path.of(uploadDir, "chunks", uploadId);
String ext = StringUtils.getFilenameExtension(fileName);
String storedName = UUID.randomUUID() + "." + ext;
Path target = Path.of(uploadDir, storedName);
try (OutputStream out = Files.newOutputStream(target)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = chunkDir.resolve("chunk_" + i);
Files.copy(chunkPath, out);
}
} catch (IOException e) {
throw new FileUploadException("合并失败", e);
}
// 清理分片
FileUtils.deleteDirectory(chunkDir.toFile());
return new FileInfo(fileName, storedName, null, Files.size(target));
}
}3. 文件下载
3.1 基础下载
java
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) {
Path filePath = Path.of(uploadDir, filename);
Resource resource = new FileSystemResource(filePath);
if (!resource.exists()) {
throw new ResourceNotFoundException("文件不存在: " + filename);
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}3.2 支持 Range 请求(断点续传/视频拖拽)
java
@GetMapping("/stream/{filename}")
public ResponseEntity<ResourceRegion> stream(
@PathVariable String filename,
@RequestHeader HttpHeaders headers) {
Resource resource = new FileSystemResource(Path.of(uploadDir, filename));
long fileLength = resource.contentLength();
// 解析 Range 头
List<HttpRange> headers.getRange();
if (ranges.isEmpty()) {
// 无 Range,返回完整文件
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new ResourceRegion(resource, 0, fileLength));
}
// 返回第一个 Range(浏览器通常只请求一个)
HttpRange range = ranges.get(0);
long start = range.getRangeStart(fileLength);
long end = range.getRangeEnd(fileLength);
long rangeLength = Math.min(1024 * 1024 * 10, end - start + 1); // 每次最多 10MB
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_RANGE,
"bytes " + start + "-" + (start + rangeLength - 1) + "/" + fileLength)
.body(new ResourceRegion(resource, start, rangeLength));
}3.3 图片在线预览
java
@GetMapping("/image/{filename}")
public ResponseEntity<Resource> preview(@PathVariable String filename) {
Resource resource = new FileSystemResource(Path.of(uploadDir, filename));
// Content-Disposition: inline 让浏览器直接显示而非下载
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.body(resource);
}4. 存储抽象
本地文件系统不适合生产环境。抽象存储接口,支持多种后端:
java
public interface StorageService {
String store(MultipartFile file);
Resource load(String filename);
void delete(String filename);
}
// 本地实现
@Service
public class LocalStorageService implements StorageService {
@Value("${app.upload.dir}")
private String uploadDir;
@Override
public String store(MultipartFile file) {
String name = UUID.randomUUID() + "." +
StringUtils.getFilenameExtension(file.getOriginalFilename());
file.transferTo(Path.of(uploadDir, name).toFile());
return name;
}
@Override
public Resource load(String filename) {
Resource resource = new FileSystemResource(Path.of(uploadDir, filename));
if (!resource.exists()) throw new ResourceNotFoundException(filename);
return resource;
}
}
// OSS 实现
@Service
public class OssStorageService implements StorageService {
@Autowired
private AmazonS3 s3Client;
@Value("${app.oss.bucket}")
private String bucket;
@Override
public String store(MultipartFile file) {
String key = "uploads/" + UUID.randomUUID() + "." +
StringUtils.getFilenameExtension(file.getOriginalFilename());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
s3Client.putObject(bucket, key, file.getInputStream(), metadata);
return key;
}
@Override
public Resource load(String filename) {
S3Object object = s3Client.getObject(bucket, "uploads/" + filename);
return new InputStreamResource(object.getObjectContent());
}
}5. 安全与校验
java
@Component
public class FileValidator {
private static final Set<String> ALLOWED_EXTENSIONS =
Set.of("jpg", "jpeg", "png", "gif", "pdf", "docx");
private static final long MAX_SIZE = 10 * 1024 * 1024; // 10MB
public void validate(MultipartFile file) {
// 1. 检查文件名(防止路径遍历攻击)
String name = file.getOriginalFilename();
if (name != null && (name.contains("..") || name.contains("/"))) {
throw new SecurityException("非法文件名");
}
// 2. 检查扩展名
String ext = StringUtils.getFilenameExtension(name);
if (ext == null || !ALLOWED_EXTENSIONS.contains(ext.toLowerCase())) {
throw new SecurityException("不允许的文件类型: " + ext);
}
// 3. 检查大小
if (file.getSize() > MAX_SIZE) {
throw new SecurityException("文件大小超过限制");
}
// 4. 检查 MIME 类型(比扩展名更可靠)
String contentType = file.getContentType();
if (!isAllowedContentType(contentType)) {
throw new SecurityException("不允许的内容类型: " + contentType);
}
// 5. 检查文件魔数(Magic Number)
if (!verifyMagicNumber(file)) {
throw new SecurityException("文件内容与扩展名不匹配");
}
}
}6. 对象存储集成
6.1 为什么需要对象存储
本地文件系统的局限:
- 服务器扩容时文件丢失(新实例没有旧实例的文件)
- 多实例部署时文件不共享
- 服务器磁盘空间有限
- 无法直接提供 CDN 加速
对象存储(S3/MinIO/OSS)解决了这些问题:分布式存储、无限容量、CDN 友好。
6.2 MinIO / S3 集成
java
// MinIO / S3 集成示例(使用 AWS S3 SDK)
@Configuration
public class S3Config {
@Bean
public AmazonS3 amazonS3() {
AWSCredentials credentials = new BasicAWSCredentials("accessKey", "secretKey");
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withEndpointConfiguration(
new EndpointConfiguration("http://minio:9000", "us-east-1"))
.withPathStyleAccessEnabled(true) // MinIO 必须开启
.build();
}
}java
@Service
public class FileStorageService {
@Autowired
private AmazonS3 s3Client;
private final String bucketName = "my-app";
// 上传文件
public String upload(MultipartFile file) {
String key = "uploads/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
try {
s3Client.putObject(bucketName, key, file.getInputStream(), metadata);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
return key;
}
// 生成预签名 URL(临时访问链接,有效期 1 小时)
public String generatePresignedUrl(String key) {
Date expiration = Date.from(Instant.now().plus(Duration.ofHours(1)));
URL url = s3Client.generatePresignedUrl(bucketName, key, expiration, HttpMethod.GET);
return url.toString();
}
private String getExtension(String filename) {
int idx = filename.lastIndexOf('.');
return idx >= 0 ? filename.substring(idx) : "";
}
}6.3 预签名 URL 的价值
私有文件不能直接公开访问,但有时需要让前端临时访问(如下载头像、预览文档)。预签名 URL 的流程:
txt
前端请求下载 → 后端生成预签名 URL(有效期 1 小时) → 返回 URL 给前端
前端用该 URL 直接访问 S3/MinIO → 不经过后端服务器
URL 过期后自动失效 → 文件不会被长期公开踩坑提醒:预签名 URL 是解决「文件私有但需要临时公开访问」的最佳方案。不要为了省事把 bucket 设为 public——那意味着任何人都能访问所有文件。另外 MinIO 的
withPathStyleAccessEnabled(true)必须开启,否则会用虚拟主机方式访问导致失败。
最佳实践:
- 文件名永远不可信——用 UUID 重命名,保留原名仅用于显示
- 校验 MIME + 魔数——扩展名可以伪造,魔数不能
- 上传目录禁止执行——确保上传目录没有脚本执行权限
- 大文件用分片——>100MB 建议分片上传,支持断点续传
- 存储抽象——本地文件系统只适合开发,生产用 OSS/S3/MinIO
- CDN 加速——静态文件通过 CDN 分发,减轻应用服务器压力
- 预签名 URL——私有文件临时访问用预签名,不要设 public bucket