OSS【进阶篇】----手把手教你实现 OSS 大文件分片上传:前端 JavaScript + 后端 Java 全流程
在实际开发中,大文件(如视频、压缩包等)直接上传容易出现超时、内存溢出等问题,分片上传是解决这一问题的主流方案。本文将以阿里云 OSS 为例,详解大文件分片上传的实现思路,并提供完整的前端(JavaScript)和后端(Java)代码示例。
一、分片上传核心原理
分片上传是将大文件切割成多个小分片(Part),分别上传到对象存储服务(如 OSS),最后再通知服务端将所有分片合并成完整文件的过程。核心流程如下:
初始化分片上传: 向 OSS 申请一个唯一标识(uploadId),用于关联后续所有分片。
切割文件并上传分片: 将本地文件按固定大小(如 5MB)切割成多个分片,并发上传,每个分片需携带uploadId和分片编号(partNumber)。
合并分片: 所有分片上传完成后,通知 OSS 将指定uploadId下的所有分片按编号合并成完整文件。
优势:
支持断点续传(可查询已上传分片,避免重复上传)。
降低单次请求压力,减少超时风险。
支持并发上传,提高上传效率。
二、核心概念说明
uploadId: 分片上传的唯一标识,初始化时由 OSS 返回,贯穿整个分片上传流程。
objectKey: OSS 中文件的唯一标识(类似文件路径 + 文件名,如uploads/202407/file.zip),用于定位最终合并后的文件。
partNumber: 分片编号(从 1 开始),用于确保分片合并时的顺序。
ETag: 每个分片上传成功后,OSS 返回的唯一标识,合并时需提交所有分片的ETag列表。
官方参考文档:https://help.aliyun.com/product/31815.html
三、完整实现代码
(一)前端实现(JavaScript)
前端负责文件切割、分片上传、进度展示等逻辑,需配合后端接口完成交互。
// OSS智能上传JavaScript逻辑
class OSSUploader {
constructor() {
this.selectedFile = null;
this.uploadId = null;
this.objectKey = null;
this.totalParts = 0;
this.uploadedParts = 0;
this.partSize = 5 * 1024 * 1024; // 5MB per part
this.isUploading = false;
this.uploadStartTime = null;
this.uploadedBytes = 0;
this.cancelUpload = false;
this.isPartUpload = false;
this.apiBase = 'http://localhost:8080/api/upload';
this.endpoints = {
preCheck: `${this.apiBase}/preCheckUpload`,
smart: `${this.apiBase}/ossMultipartUpload`,
uploadPart: `${this.apiBase}/uploadPart`,
complete: `${this.apiBase}/completeMultipartUpload`,
abort: `${this.apiBase}/abortMultipartUpload`,
};
this.fileSizeThreshold = 100 * 1024 * 1024;
this.init();
}
init() {
this.bindEvents();
}
bindEvents() {
document.getElementById('fileInput').addEventListener('change', (e) => {
this.handleFileSelect(e.target.files[0]);
});
const dropArea = document.getElementById('dropArea');
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('dragover');
});
dropArea.addEventListener('dragleave', () => {
dropArea.classList.remove('dragover');
});
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('dragover');
this.handleFileSelect(e.dataTransfer.files[0]);
});
document.getElementById('uploadBtn').addEventListener('click', () => {
this.startUpload();
});
document.getElementById('cancelBtn').addEventListener('click', () => {
this.cancelCurrentUpload();
});
}
handleFileSelect(file) {
if (!file) return;
this.selectedFile = file;
this.resetUploadState();
this.updateFileInfo();
this.showUploadControls();
}
updateFileInfo() {
if (!this.selectedFile) return;
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
fileName.textContent = this.selectedFile.name;
fileSize.textContent = this.formatFileSize(this.selectedFile.size);
fileInfo.style.display = 'block';
}
showUploadControls() {
document.getElementById('uploadControls').style.display = 'block';
document.getElementById('uploadBtn').disabled = false;
}
resetUploadState() {
this.uploadId = null;
this.objectKey = null;
this.totalParts = 0;
this.uploadedParts = 0;
this.uploadedBytes = 0;
this.isUploading = false;
this.cancelUpload = false;
this.isPartUpload = false;
document.getElementById('progressContainer').style.display = 'none';
document.getElementById('uploadResult').style.display = 'none';
document.getElementById('uploadStatus').textContent = '待上传';
}
async startUpload() {
if (!this.selectedFile || this.isUploading) return;
this.isUploading = true;
this.cancelUpload = false;
this.uploadStartTime = Date.now();
document.getElementById('uploadBtn').disabled = true;
document.getElementById('cancelBtn').disabled = false;
document.getElementById('uploadStatus').textContent = '上传中...';
try {
const result = await this.smartUpload();
if (!this.cancelUpload) {
this.showUploadSuccess(result);
}
} catch (error) {
if (!this.cancelUpload) {
this.showUploadError(error);
}
} finally {
this.isUploading = false;
document.getElementById('uploadBtn').disabled = false;
document.getElementById('cancelBtn').disabled = true;
}
}
async smartUpload() {
const prefix = 'smart-upload';
if (this.selectedFile.size > this.fileSizeThreshold) {
return await this.preCheckAndUpload(prefix);
} else {
return await this.directUpload(prefix);
}
}
async preCheckAndUpload(prefix) {
const checkData = new FormData();
checkData.append('fileName', this.selectedFile.name);
checkData.append('fileSize', this.selectedFile.size);
checkData.append('subBucketPrefix', prefix);
const checkResponse = await fetch(this.endpoints.preCheck, {
method: 'POST',
body: checkData,
});
const checkResult = await checkResponse.json();
if (checkResult.code !== 200) {
throw new Error(checkResult.message || '预检查失败');
}
if (checkResult.data.isPartUpload) {
this.isPartUpload = true;
this.uploadId = checkResult.data.uploadId;
this.objectKey = checkResult.data.objectKey;
this.totalParts = checkResult.data.totalParts;
document.getElementById('progressContainer').style.display = 'block';
document.getElementById(
'partInfo'
).textContent = `0/${this.totalParts} 分片`;
return await this.performMultipartUpload();
} else {
this.isPartUpload = false;
return await this.directUpload(prefix);
}
}
async directUpload(prefix) {
const formData = new FormData();
formData.append('file', this.selectedFile);
formData.append('subBucketPrefix', prefix);
const response = await fetch(this.endpoints.smart, {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message || '上传失败');
}
return result.data;
}
async performMultipartUpload() {
const partETags = [];
const fileSize = this.selectedFile.size;
for (let partNumber = 1; partNumber <= this.totalParts; partNumber++) {
if (this.cancelUpload) {
await this.abortMultipartUpload();
throw new Error('上传已取消');
}
const start = (partNumber - 1) * this.partSize;
const end = Math.min(start + this.partSize, fileSize);
const partData = this.selectedFile.slice(start, end);
const etag = await this.uploadPart(partNumber, partData);
partETags.push(etag);
this.uploadedParts = partNumber;
this.uploadedBytes = end;
const progress = (this.uploadedBytes / fileSize) * 100;
this.updateProgress(progress);
this.updatePartInfo();
this.updateSpeed();
}
return await this.completeMultipartUpload(partETags);
}
async uploadPart(partNumber, partData) {
const formData = new FormData();
formData.append('uploadId', this.uploadId);
formData.append('objectKey', this.objectKey);
formData.append('partNumber', partNumber);
formData.append('partData', new Blob([partData]));
const response = await fetch(this.endpoints.uploadPart, {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(`分片${partNumber}上传失败: ${result.message}`);
}
return result.data;
}
async completeMultipartUpload(partETags) {
const requestData = {
uploadId: this.uploadId,
objectKey: this.objectKey,
partsList: partETags,
};
const response = await fetch(this.endpoints.complete, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message || '完成分片上传失败');
}
return result.data;
}
async abortMultipartUpload() {
if (!this.uploadId || !this.objectKey) return;
const formData = new FormData();
formData.append('uploadId', this.uploadId);
formData.append('objectKey', this.objectKey);
try {
await fetch(this.endpoints.abort, {
method: 'POST',
body: formData,
});
} catch (error) {
console.error('取消上传失败:', error);
}
}
async cancelCurrentUpload() {
this.cancelUpload = true;
if (this.isPartUpload && this.uploadId && this.objectKey) {
await this.abortMultipartUpload();
}
document.getElementById('uploadStatus').textContent = '已取消';
}
updateProgress(percentage) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
progressBar.style.width = `${percentage}%`;
progressText.textContent = `${Math.round(percentage)}%`;
}
updatePartInfo() {
document.getElementById(
'partInfo'
).textContent = `${this.uploadedParts}/${this.totalParts} 分片`;
}
updateSpeed() {
if (!this.uploadStartTime) return;
const elapsed = (Date.now() - this.uploadStartTime) / 1000;
const speed = this.uploadedBytes / elapsed;
const remaining = (this.selectedFile.size - this.uploadedBytes) / speed;
document.getElementById('uploadSpeed').textContent =
this.formatSpeed(speed);
document.getElementById('timeRemaining').textContent =
this.formatTime(remaining);
}
showUploadSuccess(result) {
const uploadResult = document.getElementById('uploadResult');
const resultContent = document.getElementById('resultContent');
uploadResult.className = 'upload-result result-success';
uploadResult.style.display = 'block';
resultContent.innerHTML = `
<h6><i class="bi bi-check-circle-fill"></i> 上传成功!</h6>
<p class="mb-1"><strong>文件URL:</strong> <a href="${
result.fileUrl
}" target="_blank">${result.fileUrl}</a></p>
${
result.cdnUrl
? `<p class="mb-0"><strong>CDN URL:</strong> <a href="${result.cdnUrl}" target="_blank">${result.cdnUrl}</a></p>`
: ''
}
`;
document.getElementById('uploadStatus').innerHTML =
'<span class="status-indicator status-success"></span>上传完成';
}
showUploadError(error) {
const uploadResult = document.getElementById('uploadResult');
const resultContent = document.getElementById('resultContent');
uploadResult.className = 'upload-result result-error';
uploadResult.style.display = 'block';
resultContent.innerHTML = `
<h6><i class="bi bi-exclamation-triangle-fill"></i> 上传失败</h6>
<p class="mb-0">${error.message}</p>
`;
document.getElementById('uploadStatus').innerHTML =
'<span class="status-indicator status-error"></span>上传失败';
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatSpeed(bytesPerSecond) {
return this.formatFileSize(bytesPerSecond) + '/s';
}
formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return '计算中...';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes > 0) {
return `${minutes}分${remainingSeconds}秒`;
} else {
return `${remainingSeconds}秒`;
}
}
}
// 初始化上传器
document.addEventListener('DOMContentLoaded', () => {
new OSSUploader();
});
(二)后端实现(Java + Spring Boot)
后端负责与阿里云 OSS 交互,处理初始化分片、上传分片、合并分片等核心逻辑,提供前端所需的 API 接口。
1. 依赖配置(pom.xml)
<!-- 阿里云OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis(用于存储分片上传状态,可选) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. OSS 配置类(读取阿里云密钥)
@RefreshScope
@Data
@ConfigurationProperties(prefix = "aliyun")
@Component
public class AliYunConfig {
private String accessKey; // 阿里云AccessKeyId
private String accessKeySecret; // 阿里云AccessKeySecret
private String ossBucket; // 存储桶名称
private String ossEndpoint; // OSS地域节点(如oss-cn-beijing.aliyuncs.com)
private String multipartPartSize; // 分片大小
private String multipartThreshold; // 文件超过阈值即需要分片上传
@Bean
public OSS oSSClient() {
return new OSSClient(ossEndpoint, accessKey, accessKeySecret);
}
}
3. 核心业务接口(Controller)
/**
* 预检查文件上传方式(大文件用这个接口)
*
* @param fileName 文件名
* @param fileSize 文件大小
* @param subBucketPrefix 前置路径
* @return 上传方式信息
*/
@PostMapping("/preCheckUpload")
public CommonResponse<OssUploadResponse> preCheckUpload(
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") Long fileSize,
@RequestParam(value = "subBucketPrefix", required = false) String subBucketPrefix) {
return ossMultipartService.preCheckUpload(fileName, fileSize, subBucketPrefix);
}
/**
* 根据文件大小自动选择上传方式(小文件用这个接口)
*
* @param file 上传的文件
* @param subBucketPrefix 前置路径
* @return 上传结果
*/
@PostMapping("/ossMultipartUpload")
public CommonResponse<OssUploadResponse> smartUpload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "subBucketPrefix", required = false) String subBucketPrefix) {
return ossMultipartService.smartUpload(file, subBucketPrefix);
}
/**
* 查询成功上传分片
*
* @param uploadId
* @return
*/
@GetMapping("/checkParts")
public CommonResponse<List<Integer>> checkUploadedParts(@RequestParam("uploadId") String uploadId) {
return ossMultipartService.getUploadedParts(uploadId);
}
/**
* 上传文件分片
*
* @param uploadId 上传ID
* @param objectKey 对象key
* @param partNumber 分片编号
* @param partData 分片数据
* @return 分片ETag
*/
@PostMapping("/uploadPart")
public CommonResponse<String> uploadPart(
@RequestParam("uploadId") String uploadId,
@RequestParam("objectKey") String objectKey,
@RequestParam("partNumber") int partNumber,
@RequestParam("partData") MultipartFile partData) throws IOException {
Assert.isFalse(partData.isEmpty(), "分片数据不能为空");
log.debug("上传分片: uploadId={}, partNumber={}, size={}", uploadId, partNumber, partData.getSize());
return ossMultipartService.uploadPart(uploadId, objectKey, partNumber, partData.getBytes());
}
/**
* 完成对分片的合并
*
* @param request 完成分片上传请求
* @return 上传结果
*/
@PostMapping("/completeMultipartUpload")
public CommonResponse<MultipartUploadResponse> completeMultipartUpload(@RequestBody @Validated CompleteMultipartRequest request) {
Assert.isFalse(request.getPartsList() == null || request.getPartsList().isEmpty(), "分片ETag列表不能为空");
log.info("完成分片上传: uploadId={}, objectKey={}, partCount={}", request.getUploadId(), request.getObjectKey(), request.getPartsList().size());
return ossMultipartService.completeMultipartUpload(request.getUploadId(), request.getObjectKey(), request.getPartsList());
}
/**
* 取消分片上传
*
* @param uploadId 上传ID
* @param objectKey 对象key
* @return 取消结果
*/
@PostMapping("/abortMultipartUpload")
public CommonResponse<String> abortMultipartUpload(
@RequestParam("uploadId") String uploadId,
@RequestParam("objectKey") String objectKey) {
log.info("取消分片上传: uploadId={}, objectKey={}", uploadId, objectKey);
return ossMultipartService.abortMultipartUpload(uploadId, objectKey);
}
}
4. 业务实现(Service)
/**
* OSS分片上传服务
*/
@Service
@Slf4j
public class OssMultipartService {
private static final String UPLOAD_PARTS_KEY_PREFIX = "oss:upload_parts:";
private static final long UPLOAD_STATUS_EXPIRE_TIME = 7 * 24 * 60 * 60; // 上传状态保留7天
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
@Autowired
private OSS ossClient;
@Autowired
private AliYunConfig aliYunConfig;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 初始化分片上传(内部方法)
*
* @param file 文件
* @param subBucketPrefix 前置路径
* @return 分片大小和总共的分片数
*/
private InitiateMultipartResponse initiateMultipartUpload(MultipartFile file, String subBucketPrefix) {
try {
String objectKey = generateObjectKey(file.getOriginalFilename(), subBucketPrefix);
long fileSize = file.getSize();
long partSize = Long.parseLong(aliYunConfig.getMultipartPartSize());
int totalParts = (int) Math.ceil((double) fileSize / partSize);
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(
aliYunConfig.getOssBucket(), objectKey);
InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);
log.info("初始化分片上传成功,UploadId: {}, ObjectKey: {}, 总分片数: {}",
result.getUploadId(), objectKey, totalParts);
return new InitiateMultipartResponse(
result.getUploadId(),
file.getOriginalFilename(),
objectKey,
fileSize,
totalParts,
partSize);
} catch (Exception e) {
log.error("初始化分片上传失败", e);
throw new RuntimeException("初始化分片上传失败: " + e.getMessage());
}
}
/**
* 上传文件分片
*
* @param uploadId 上传ID
* @param objectKey 对象key
* @param partNumber 分片编号
* @param partData 分片数据
* @return 分片ETag
*/
public CommonResponse<String> uploadPart(String uploadId, String objectKey, int partNumber, byte[] partData) {
try {
InputStream inputStream = new ByteArrayInputStream(partData);
UploadPartRequest uploadPartRequest = new UploadPartRequest();
uploadPartRequest.setBucketName(aliYunConfig.getOssBucket());
uploadPartRequest.setKey(objectKey);
uploadPartRequest.setUploadId(uploadId);
uploadPartRequest.setPartNumber(partNumber);
uploadPartRequest.setInputStream(inputStream);
uploadPartRequest.setPartSize(partData.length);
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
log.debug("上传分片成功,PartNumber: {}, ETag: {}", partNumber, uploadPartResult.getETag());
log.debug("缓存开始更新成功上传分片信息");
addPartNumber(uploadId, partNumber);
return CommonResponse.succeed(uploadPartResult.getETag());
} catch (Exception e) {
log.error("上传分片失败,PartNumber: {}", partNumber, e);
return CommonResponse.fail("上传分片失败: " + e.getMessage());
}
}
/**
* 完成分片上传
*
* @param uploadId 上传ID
* @param objectKey 对象key
* @param partETags 分片ETag列表
* @return 上传结果
*/
public CommonResponse<MultipartUploadResponse> completeMultipartUpload(String uploadId, String objectKey,
List<String> partETags) {
try {
List<PartETag> partETagList = new ArrayList<>();
for (int i = 0; i < partETags.size(); i++) {
partETagList.add(new PartETag(i + 1, partETags.get(i)));
}
CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
aliYunConfig.getOssBucket(), objectKey, uploadId, partETagList);
ossClient.completeMultipartUpload(completeRequest);
log.info("完成分片上传成功,ObjectKey: {}", objectKey);
return CommonResponse.succeed(new MultipartUploadResponse(
uploadId,
extractFileName(objectKey),
objectKey,
0L, // 文件大小需要单独获取
partETags.size(),
"SUCCESS"));
} catch (Exception e) {
log.error("完成分片上传失败", e);
return CommonResponse.fail("完成分片上传失败: " + e.getMessage());
}
}
/**
* 取消分片上传
*
* @param uploadId 上传ID
* @param objectKey 对象key
*/
public CommonResponse<String> abortMultipartUpload(String uploadId, String objectKey) {
try {
AbortMultipartUploadRequest abortRequest = new AbortMultipartUploadRequest(
aliYunConfig.getOssBucket(), objectKey, uploadId);
ossClient.abortMultipartUpload(abortRequest);
log.info("取消分片上传成功,UploadId: {}, ObjectKey: {}", uploadId, objectKey);
return CommonResponse.succeed("取消成功");
} catch (Exception e) {
log.error("取消分片上传失败", e);
return CommonResponse.fail("取消分片上传失败: " + e.getMessage());
}
}
/**
* 简单上传(小文件,内部方法)
*
* @param file 文件
* @param subBucketPrefix 前置路径
* @return 上传结果
*/
private MultipartUploadResponse simpleUpload(MultipartFile file, String subBucketPrefix) {
try {
String objectKey = generateObjectKey(file.getOriginalFilename(), subBucketPrefix);
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
ossClient.putObject(
aliYunConfig.getOssBucket(),
objectKey,
file.getInputStream(),
metadata);
log.info("简单上传成功,ObjectKey: {}", objectKey);
return new MultipartUploadResponse(
null,
file.getOriginalFilename(),
objectKey,
file.getSize(),
1,
"SUCCESS");
} catch (IOException e) {
log.error("简单上传失败", e);
throw new RuntimeException("简单上传失败: " + e.getMessage());
}
}
/**
* 预检查上传方式(只检查不上传文件)
*
* @param fileName 文件名
* @param fileSize 文件大小
* @param subBucketPrefix 前置路径
* @return 上传方式信息
*/
public CommonResponse<OssUploadResponse> preCheckUpload(String fileName, Long fileSize, String subBucketPrefix) {
try {
Assert.isFalse(fileName == null || fileName.trim().isEmpty(), "文件名不能为空");
Assert.isFalse(fileSize == null || fileSize <= 0, "文件大小不能为空或小于等于0");
log.info("预检查文件上传: {}, 大小: {} bytes, 前置路径: {}", fileName, fileSize, subBucketPrefix);
long threshold = Long.parseLong(aliYunConfig.getMultipartThreshold());
if (fileSize > threshold) {
// 大文件, 需要切片上传
String objectKey = generateObjectKey(fileName, subBucketPrefix);
long partSize = Long.parseLong(aliYunConfig.getMultipartPartSize());
int totalParts = (int) Math.ceil((double) fileSize / partSize);
// 初始化分片上传
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(
aliYunConfig.getOssBucket(), objectKey);
InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);
log.info("预检查-初始化分片上传成功,UploadId: {}, ObjectKey: {}, 总分片数: {}",
result.getUploadId(), objectKey, totalParts);
OssUploadResponse response = OssUploadResponse.createMultipartUploadResponse(result.getUploadId());
response.setFileName(fileName);
response.setTotalParts(totalParts);
response.setFileSize(fileSize);
response.setObjectKey(objectKey);
return CommonResponse.succeed(response);
} else {
// 小文件可以直接上传
OssUploadResponse response = new OssUploadResponse();
response.setIsPartUpload(false);
response.setFileName(fileName);
response.setFileSize(fileSize);
return CommonResponse.succeed(response);
}
} catch (Exception e) {
log.error("预检查失败", e);
return CommonResponse.fail("预检查失败: " + e.getMessage());
}
}
/**
* 智能上传(根据文件大小选择上传方式)
*
* @param file 文件
* @param subBucketPrefix 前置路径
* @return 上传结果
*/
public CommonResponse<OssUploadResponse> smartUpload(MultipartFile file, String subBucketPrefix) {
try {
Assert.isFalse(file.isEmpty(), "文件不能为空");
log.info("开始上传文件: {}, 大小: {} bytes, 前置路径: {}", file.getOriginalFilename(), file.getSize(), subBucketPrefix);
long fileSize = file.getSize();
long threshold = Long.parseLong(aliYunConfig.getMultipartThreshold());
if (fileSize > threshold) {
// 大文件, 需要切片上传
InitiateMultipartResponse initResponse = initiateMultipartUpload(file, subBucketPrefix);
OssUploadResponse response = OssUploadResponse.createMultipartUploadResponse(initResponse.getUploadId());
// 设置分片上传的必要信息
response.setFileName(initResponse.getFileName());
response.setTotalParts(initResponse.getTotalParts());
response.setFileSize(initResponse.getFileSize());
response.setObjectKey(initResponse.getObjectKey());
return CommonResponse.succeed(response);
} else {
// 小文件直接上传
MultipartUploadResponse simpleResult = simpleUpload(file, subBucketPrefix);
return CommonResponse.succeed(OssUploadResponse.createSimpleUploadResponse(simpleResult.getFileSize(), simpleResult.getFileUrl()));
}
} catch (Exception e) {
log.error("上传失败", e);
return CommonResponse.fail("上传失败: " + e.getMessage());
}
}
/**
* 生成对象key
*
* @param originalFilename 原始文件名
* @param subBucketPrefix 前置路径
* @return 对象key
*/
private String generateObjectKey(String originalFilename, String subBucketPrefix) {
String dateStr = new SimpleDateFormat("yyyy/MM/dd").format(new Date());
String uuid = UUID.randomUUID().toString().replace("-", "");
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
// 如果有前置路径,则使用前置路径,否则使用默认的uploads路径
String basePath = (subBucketPrefix != null && !subBucketPrefix.trim().isEmpty())
? subBucketPrefix.trim()
: "uploads";
// 确保路径格式正确,移除开头和结尾的斜杠
if (basePath.startsWith("/")) {
basePath = basePath.substring(1);
}
if (basePath.endsWith("/")) {
basePath = basePath.substring(0, basePath.length() - 1);
}
return String.format("%s/%s/%s%s", basePath, dateStr, uuid, extension);
}
/**
* 从对象key中提取文件名
*
* @param objectKey 对象key
* @return 文件名
*/
private String extractFileName(String objectKey) {
if (objectKey == null) {
return null;
}
int lastSlash = objectKey.lastIndexOf("/");
return lastSlash >= 0 ? objectKey.substring(lastSlash + 1) : objectKey;
}
/**
* 添加分片编号到字符串(格式:1,2,3,4)
*/
private void addPartNumber(String uploadId, int partNumber) {
String key = UPLOAD_PARTS_KEY_PREFIX + uploadId;
// 使用 SADD 命令添加到集合(自动去重)
stringRedisTemplate.opsForSet().add(key, String.valueOf(partNumber));
// 设置过期时间
stringRedisTemplate.expire(key, UPLOAD_STATUS_EXPIRE_TIME, TimeUnit.SECONDS);
}
/**
* 检查已上传的分片
*
* @param uploadId
* @return
*/
public CommonResponse<List<Integer>> getUploadedParts(String uploadId) {
String key = UPLOAD_PARTS_KEY_PREFIX + uploadId;
Set<String> parts = stringRedisTemplate.opsForSet().members(key);
return CommonResponse.succeed(parts != null
? parts.stream()
.map(Integer::valueOf)
.sorted()
.collect(Collectors.toList())
: Collections.emptyList());
}
}
四、关键优化与注意事项
分片大小选择:
分片不宜过大(否则单次上传仍可能超时)或过小(增加请求次数和合并压力),建议设置为 5MB~10MB(需与 OSS 的分片大小限制匹配)。
并发控制:
前端上传分片时需限制并发数(如同时上传 3~5 个),避免浏览器或服务器资源耗尽。
断点续传实现:
通过checkParts接口查询已上传分片,仅上传未完成的部分,核心是用 Redis 的 Set 结构记录已上传的partNumber。
错误重试机制:
分片上传失败时,可重试该分片(需保证partNumber和uploadId不变)。
跨域问题:
后端需配置@CrossOrigin或通过网关处理跨域请求,避免前端请求被拦截。
资源清理:
取消上传或上传失败后,需调用abortMultipartUpload接口通知 OSS 删除已上传的分片,避免占用存储空间。
五、总结
通过分片上传,可有效解决大文件上传的稳定性和效率问题。本文提供的方案涵盖了从前端文件切割、并发上传到后端 OSS 交互、断点续传的完整流程,可直接应用于生产环境(需根据实际业务调整参数和安全配置)。
如需进一步优化,可考虑添加分片上传进度实时同步、大文件 MD5 校验(避免文件损坏)等功能。
完结!!!