分片上传(Multipart Upload)是大文件上传的常用方案,可以解决大文件上传超时、网络不稳定等问题。以下是基于Spring Boot和Minio实现分片上传的完整方案。
1. 分片上传原理
- 初始化上传:创建分片上传任务,获取唯一上传ID
- 上传分片:将文件分成多个分片依次上传
- 完成上传:所有分片上传完成后合并文件
- 取消上传:可中途取消并删除已上传分片
2. 工具类扩展
在原有MinioUtil基础上增加分片上传相关方法:
java
import io.minio.messages.Part;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class MinioUtil {
// ... 原有代码 ...
// 保存上传ID的缓存
private final Map<String, String> uploadIdCache = new ConcurrentHashMap<>();
/**
* 初始化分片上传
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @return 上传ID
*/
public String initMultiPartUpload(String bucketName, String objectName) throws Exception {
String uploadId = minioClient.initiateMultipartUpload(
InitiateMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()).uploadId();
uploadIdCache.put(objectName, uploadId);
return uploadId;
}
/**
* 上传分片
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param uploadId 上传ID
* @param partNumber 分片序号(1-based)
* @param inputStream 分片数据流
* @param partSize 分片大小
* @return 分片信息
*/
public Part uploadPart(String bucketName, String objectName, String uploadId,
int partNumber, InputStream inputStream, long partSize) throws Exception {
String etag = minioClient.uploadPart(
UploadPartArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.partNumber(partNumber)
.stream(inputStream, partSize, -1)
.build()).etag();
return new Part(partNumber, etag);
}
/**
* 完成分片上传
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param uploadId 上传ID
* @param parts 分片列表
*/
public void completeMultiPartUpload(String bucketName, String objectName,
String uploadId, Part[] parts) throws Exception {
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.parts(parts)
.build());
uploadIdCache.remove(objectName);
}
/**
* 取消分片上传
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param uploadId 上传ID
*/
public void abortMultiPartUpload(String bucketName, String objectName, String uploadId) throws Exception {
minioClient.abortMultipartUpload(
AbortMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.build());
uploadIdCache.remove(objectName);
}
/**
* 获取上传ID(从缓存)
* @param objectName 对象名称
* @return 上传ID
*/
public String getUploadId(String objectName) {
return uploadIdCache.get(objectName);
}
}
3. 控制器实现
java
@RestController
@RequestMapping("/multipart")
public class MultipartUploadController {
@Autowired
private MinioUtil minioUtil;
@Value("${minio.bucketName}")
private String bucketName;
// 保存各分片上传进度
private final Map<String, List<Part>> partProgressMap = new ConcurrentHashMap<>();
/**
* 初始化分片上传
*/
@PostMapping("/init")
public ResponseEntity<?> initUpload(@RequestParam String fileName) {
try {
String uploadId = minioUtil.initMultiPartUpload(bucketName, fileName);
partProgressMap.put(fileName, new ArrayList<>());
return ResponseEntity.ok(Map.of(
"uploadId", uploadId,
"code", 200,
"message", "初始化成功"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"code", 500,
"message", "初始化失败: " + e.getMessage()
));
}
}
/**
* 上传分片
*/
@PostMapping("/upload")
public ResponseEntity<?> uploadPart(
@RequestParam String fileName,
@RequestParam String uploadId,
@RequestParam int partNumber,
@RequestParam MultipartFile file) {
try {
// 获取或创建分片列表
List<Part> parts = partProgressMap.computeIfAbsent(fileName, k -> new ArrayList<>());
// 上传分片
Part part = minioUtil.uploadPart(
bucketName, fileName, uploadId,
partNumber, file.getInputStream(), file.getSize());
// 保存分片信息
parts.add(part);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "分片上传成功",
"partNumber", partNumber,
"etag", part.etag()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"code", 500,
"message", "分片上传失败: " + e.getMessage()
));
}
}
/**
* 完成分片上传
*/
@PostMapping("/complete")
public ResponseEntity<?> completeUpload(
@RequestParam String fileName,
@RequestParam String uploadId) {
try {
List<Part> parts = partProgressMap.get(fileName);
if (parts == null || parts.isEmpty()) {
throw new RuntimeException("没有找到分片信息");
}
// 完成上传
minioUtil.completeMultiPartUpload(
bucketName, fileName, uploadId,
parts.toArray(new Part[0]));
// 清理缓存
partProgressMap.remove(fileName);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "文件上传完成",
"fileName", fileName,
"url", minioUtil.getFileUrl(bucketName, fileName)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"code", 500,
"message", "完成上传失败: " + e.getMessage()
));
}
}
/**
* 取消上传
*/
@PostMapping("/abort")
public ResponseEntity<?> abortUpload(
@RequestParam String fileName,
@RequestParam String uploadId) {
try {
minioUtil.abortMultiPartUpload(bucketName, fileName, uploadId);
partProgressMap.remove(fileName);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "上传已取消"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"code", 500,
"message", "取消上传失败: " + e.getMessage()
));
}
}
}
4. 前端实现示例
前端可以使用以下逻辑实现分片上传:
js
// 配置
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片大小
async function uploadFile(file) {
// 1. 初始化上传
const initResponse = await fetch(`/multipart/init?fileName=${file.name}`);
const initData = await initResponse.json();
const uploadId = initData.uploadId;
// 2. 分片上传
const chunks = Math.ceil(file.size / CHUNK_SIZE);
const parts = [];
for (let i = 0; i < chunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileName', file.name);
formData.append('uploadId', uploadId);
formData.append('partNumber', i + 1);
const uploadResponse = await fetch('/multipart/upload', {
method: 'POST',
body: formData
});
const uploadData = await uploadResponse.json();
console.log(`分片 ${i + 1}/${chunks} 上传完成`, uploadData);
}
// 3. 完成上传
const completeResponse = await fetch('/multipart/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `fileName=${encodeURIComponent(file.name)}&uploadId=${uploadId}`
});
const completeData = await completeResponse.json();
console.log('文件上传完成', completeData);
return completeData.url;
}
5. 断点续传实现
要实现断点续传功能,可以增加以下方法:
java
/**
* 列出已上传的分片
*/
@GetMapping("/listParts")
public ResponseEntity<?> listParts(
@RequestParam String fileName,
@RequestParam String uploadId) {
try {
List<Part> existingParts = minioUtil.listParts(bucketName, fileName, uploadId);
return ResponseEntity.ok(Map.of(
"code", 200,
"data", existingParts.stream()
.map(p -> Map.of(
"partNumber", p.partNumber(),
"etag", p.etag()
))
.collect(Collectors.toList())
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"code", 500,
"message", "查询分片失败: " + e.getMessage()
));
}
}
然后在MinioUtil中添加:
java
/**
* 列出已上传的分片
*/
public List<Part> listParts(String bucketName, String objectName, String uploadId) throws Exception {
return minioClient.listParts(
ListPartsArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.build()).result().partList();
}
前端可以首先查询已上传的分片,然后只上传缺失的分片:
js
async function resumeUpload(file, uploadId) {
// 查询已上传的分片
const partsResponse = await fetch(`/multipart/listParts?fileName=${file.name}&uploadId=${uploadId}`);
const partsData = await partsResponse.json();
const uploadedParts = new Set(partsData.data.map(p => p.partNumber));
// 继续上传剩余分片
const chunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < chunks; i++) {
if (!uploadedParts.has(i + 1)) {
// 上传缺失的分片...
}
}
// 完成上传...
}
6. 注意事项
- 分片大小:建议设置为5MB-15MB,过小会增加请求次数,过大会增加失败风险
- 并发上传:可以并行上传多个分片以提高速度,但需要注意服务器压力
- 超时设置:为分片上传操作设置合理的超时时间
- 清理机制:实现定时任务清理未完成的分片上传,避免存储空间浪费
- 安全性:验证上传ID和分片序号,防止恶意请求