第 71 章:文件上传与对象存储
学习目标
- 掌握大文件分片上传与断点续传
- 学会接入 MinIO / 阿里 OSS 对象存储
- 实现文件安全校验与防盗链
一、上传的三种方案
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 本地存储 | 简单 | 多服务器同步难、单点 | 单机应用 |
| 对象存储 | 弹性、便宜 | 走外网 | 绝大多数场景 |
| CDN | 全球加速 | 适合静态不常变 | 图片、视频 |
二、基础上传(Spring Boot)
yaml
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB # ① 单文件大小
max-request-size: 200MB # ② 整个请求大小
file-size-threshold: 10MB # ③ 超过此大小才落盘java
@RestController
@RequestMapping("/api/file")
public class FileController {
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
// ① 校验
if (file.isEmpty()) {
throw new BusinessException("文件不能为空");
}
// ② 校验类型
String contentType = file.getContentType();
if (!ALLOWED_TYPES.contains(contentType)) {
throw new BusinessException("不支持的文件类型");
}
// ③ 校验大小
if (file.getSize() > 10 * 1024 * 1024) {
throw new BusinessException("文件超过 10MB");
}
// ④ 生成文件名(避免冲突)
String ext = getExtension(file.getOriginalFilename());
String filename = UUID.randomUUID() + "." + ext;
// ⑤ 保存
File dest = new File("/data/upload/" + filename);
file.transferTo(dest);
return Result.ok("/upload/" + filename);
}
}三、MinIO 集成(自建对象存储)
xml
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.10</version>
</dependency>yaml
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: taskflowjava
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient(
@Value("${minio.endpoint}") String endpoint,
@Value("${minio.access-key}") String accessKey,
@Value("${minio.secret-key}") String secretKey) {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
@Component
@RequiredArgsConstructor
public class MinioService {
private final MinioClient minioClient;
@Value("${minio.bucket}")
private String bucket;
// ① 上传文件
public String upload(InputStream stream, String filename, String contentType)
throws Exception {
ensureBucket();
// ② 用日期分目录
String objectName = LocalDate.now().toString() + "/" +
UUID.randomUUID() + "-" + filename;
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(stream, -1, 10485760) // 10MB
.contentType(contentType)
.build());
return getPresignedUrl(objectName);
}
// ③ 生成临时访问 URL(默认 7 天)
public String getPresignedUrl(String objectName) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.method(Method.GET)
.expiry(7, TimeUnit.DAYS)
.build());
} catch (Exception e) {
throw new BusinessException("生成访问链接失败");
}
}
// ④ 删除
public void delete(String objectName) throws Exception {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
}
private void ensureBucket() throws Exception {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder()
.bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
}
}java
@RestController
@RequestMapping("/api/file")
@RequiredArgsConstructor
public class FileController {
private final MinioService minioService;
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
try {
String url = minioService.upload(
file.getInputStream(),
file.getOriginalFilename(),
file.getContentType());
return Result.ok(url);
} catch (Exception e) {
throw new BusinessException("上传失败");
}
}
}四、阿里云 OSS 集成
xml
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>yaml
aliyun:
oss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key: ${OSS_AK}
secret-key: ${OSS_SK}
bucket: taskflowjava
@Component
public class OssService {
private final OSS ossClient;
public OssService(
@Value("${aliyun.oss.endpoint}") String endpoint,
@Value("${aliyun.oss.access-key}") String ak,
@Value("${aliyun.oss.secret-key}") String sk,
@Value("${aliyun.oss.bucket}") String bucket) {
this.ossClient = new OSSClientBuilder().build(endpoint, ak, sk);
this.bucket = bucket;
}
public String upload(InputStream stream, String filename) {
String objectName = "uploads/" + UUID.randomUUID() + "-" + filename;
ossClient.putObject(bucket, objectName, stream);
// 生成访问 URL
Date expiry = new Date(System.currentTimeMillis() + 3600 * 1000);
return ossClient.generatePresignedUrl(bucket, objectName, expiry).toString();
}
// 生成前端直传 Policy(前端直传 OSS,不经过应用服务器)
public Map<String, String> generatePostPolicy(String dir) {
long expireTime = 30;
Date expire = new Date(System.currentTimeMillis() + expireTime * 1000);
PolicyConditions policy = new PolicyConditions();
policy.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600);
policy.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expire, policy);
String signature = ossClient.calculatePostSignature(postPolicy);
Map<String, String> map = new HashMap<>();
map.put("accessKeyId", accessKey);
map.put("policy", postPolicy);
map.put("signature", signature);
map.put("dir", dir);
map.put("expire", String.valueOf(expireTime));
return map;
}
}五、大文件分片上传
场景:上传 10GB 视频,单次 HTTP 请求不可能。
后端实现
java
@Service
@RequiredArgsConstructor
public class ChunkUploadService {
private final RedisTemplate<String, Object> redis;
private final MinioService minioService;
// ① 初始化分片上传
public String initUpload(String filename, Long totalSize, String md5) {
// 检查是否已秒传(MD5 已存在)
if (Boolean.TRUE.equals(redis.hasKey("file:md5:" + md5))) {
return (String) redis.opsForValue().get("file:md5:" + md5);
}
// 生成 uploadId
String uploadId = UUID.randomUUID().toString();
// 保存分片信息
Map<String, Object> uploadInfo = new HashMap<>();
uploadInfo.put("filename", filename);
uploadInfo.put("totalSize", totalSize);
uploadInfo.put("md5", md5);
uploadInfo.put("chunks", new HashSet<Integer>()); // 已上传分片
uploadInfo.put("createTime", System.currentTimeMillis());
redis.opsForValue().set("upload:" + uploadId, uploadInfo, 24, TimeUnit.HOURS);
return uploadId;
}
// ② 上传分片
public void uploadChunk(String uploadId, int chunkIndex, MultipartFile chunk)
throws Exception {
Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
.get("upload:" + uploadId);
if (info == null) {
throw new BusinessException("上传会话不存在");
}
// 保存分片到临时目录
File chunkFile = new File("/tmp/upload/" + uploadId + "/" + chunkIndex);
chunkFile.getParentFile().mkdirs();
chunk.transferTo(chunkFile);
// 记录已上传分片
Set<Integer> uploadedChunks = (Set<Integer>) info.get("chunks");
uploadedChunks.add(chunkIndex);
info.put("chunks", uploadedChunks);
redis.opsForValue().set("upload:" + uploadId, info, 24, TimeUnit.HOURS);
}
// ③ 合并分片
public String mergeChunks(String uploadId) throws Exception {
Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
.get("upload:" + uploadId);
if (info == null) {
throw new BusinessException("上传会话不存在");
}
String filename = (String) info.get("filename");
String md5 = (String) info.get("md5");
File chunkDir = new File("/tmp/upload/" + uploadId);
// 合并分片
File merged = new File("/tmp/upload/" + uploadId + "-merged-" + filename);
try (FileOutputStream fos = new FileOutputStream(merged)) {
File[] chunks = chunkDir.listFiles();
Arrays.sort(chunks, Comparator.comparing(File::getName));
for (File chunk : chunks) {
Files.copy(chunk.toPath(), fos);
}
}
// 上传到 MinIO
try (FileInputStream fis = new FileInputStream(merged)) {
String url = minioService.upload(fis, filename, "application/octet-stream");
redis.opsForValue().set("file:md5:" + md5, url, 30, TimeUnit.DAYS);
return url;
} finally {
// 清理临时文件
FileUtils.deleteDirectory(chunkDir);
merged.delete();
redis.delete("upload:" + uploadId);
}
}
}Controller
java
@RestController
@RequestMapping("/api/upload")
@RequiredArgsConstructor
public class ChunkUploadController {
private final ChunkUploadService service;
@PostMapping("/init")
public Result<Map<String, String>> init(
@RequestBody InitUploadDTO dto) {
String uploadId = service.initUpload(
dto.getFilename(), dto.getSize(), dto.getMd5());
return Result.ok(Map.of("uploadId", uploadId));
}
@PostMapping("/chunk")
public Result<Void> uploadChunk(
@RequestParam String uploadId,
@RequestParam Integer chunkIndex,
@RequestParam MultipartFile chunk) {
try {
service.uploadChunk(uploadId, chunkIndex, chunk);
return Result.ok();
} catch (Exception e) {
throw new BusinessException("分片上传失败");
}
}
@PostMapping("/merge")
public Result<String> merge(@RequestParam String uploadId) {
try {
return Result.ok(service.mergeChunks(uploadId));
} catch (Exception e) {
throw new BusinessException("合并失败");
}
}
}六、断点续传 + 秒传
java
// ① 断点续传:上传前查询已上传的分片
@GetMapping("/upload/status")
public Result<Set<Integer>> getUploadedChunks(String uploadId) {
Map<String, Object> info = (Map<String, Object>) redis.opsForValue()
.get("upload:" + uploadId);
if (info == null) {
return Result.ok(new HashSet<>());
}
return Result.ok((Set<Integer>) info.get("chunks"));
}
// 前端跳过已上传的分片,只传缺失的
// ② 秒传:MD5 已存在时直接返回 URL
@PostMapping("/init")
public Result<Map<String, Object>> init(@RequestBody InitUploadDTO dto) {
String md5 = dto.getMd5();
String existingUrl = (String) redis.opsForValue().get("file:md5:" + md5);
if (existingUrl != null) {
// 秒传:直接返回 URL,不占用存储
return Result.ok(Map.of("url", existingUrl, "instant", true));
}
String uploadId = service.initUpload(dto.getFilename(), dto.getSize(), md5);
return Result.ok(Map.of("uploadId", uploadId, "instant", false));
}七、文件安全
校验
java
// ① 真实类型校验(不信任 Content-Type 和后缀)
public String getRealType(InputStream stream) {
try {
// 读文件头几个字节(魔数)
byte[] header = new byte[12];
stream.read(header);
stream.reset();
if (header[0] == (byte) 0xFF && header[1] == (byte) 0xD8) return "image/jpeg";
if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F') return "image/gif";
if (header[0] == (byte) 0x89 && header[1] == 'P') return "image/png";
if (header[0] == 'P' && header[1] == 'K') return "application/zip";
return "unknown";
} catch (Exception e) {
return "unknown";
}
}
// ② 病毒扫描(接入 ClamAV)
Process p = Runtime.getRuntime().exec("clamscan --infected " + file.getAbsolutePath());防盗链(Referer 校验)
nginx
# Nginx 配置:只允许特定域名访问
location /files/ {
valid_referers none blocked taskflow.com *.taskflow.com;
if ($invalid_referer) {
return 403;
}
}临时签名 URL
java
// MinIO / OSS 都支持生成带有效期的 URL,过期失效
String url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.method(Method.GET)
.expiry(1, TimeUnit.HOURS) // ① 1 小时后过期
.build());八、CDN 加速
yaml
# 把 OSS 的域名接入 CDN
cdn:
domain: cdn.taskflow.com
origin: taskflow.oss-cn-hangzhou.aliyuncs.com
cache-rules:
- path: "*.jpg"
ttl: 30d
- path: "*.html"
ttl: 1h九、本章小结
| 要点 | 关键 |
|---|---|
| 方案 | 本地(不推荐)/ 对象存储(推荐)/ CDN(静态资源) |
| MinIO | 自建对象存储,S3 兼容 |
| 阿里 OSS | 商用,云端直传 Policy 减轻服务器压力 |
| 分片上传 | 大文件必备(视频、压缩包) |
| 断点续传 | 服务端记录已上传分片 |
| 秒传 | MD5 已存在直接返回 URL |
| 安全 | 真实类型校验 + 病毒扫描 + 防盗链 + 临时签名 |
动手练习
练习 1:基础题
集成 MinIO,实现头像上传:用户上传图片 → 保存到 MinIO → 返回访问 URL。
练习 2:进阶题
实现一个视频分片上传功能:支持 100MB+ 视频、断点续传(刷新页面后能继续上传)、秒传。
练习 3:思考题
你的系统允许用户上传图片。要避免:
- 上传木马(伪装成图片的 PHP)
- 上传超大图片占用存储
- 同一图片被上传 100 次(应该秒传) 如何设计?
下一章:第 72 章:WebSocket 与实时通信 →