springboot+vue3+vue-simple-uploader轻松实现大文件分片上传Minio
把需要上传的视频分成多个小块上传到,最后再合并成一个视频进行存储。我这里上传到自有的Minio,其它存储应该也是大同小异。前端:vue3+vue-simple-uploader,后端:springboot+Minio
·
最近在写视频课程的上传,需要上传的视频几百MB到几个G不等,普通的上传都限制了文件的大小,况且上传的文件太大的话会超时、异常等。所以这时候需要考虑分片上传了,把需要上传的视频分成多个小块上传到,最后再合并成一个视频进行存储。我这里上传到自有的Minio,其它存储应该也是大同小异。
前端:vue3+vue-simple-uploader
后端:springboot+Minio
先看前端效果
vue-simple-uploader原生组件的样式效果很不错了,vue-simple-uploader文档
下面这个是我上传成功后自定义的样式
再来看看流程图思路

有了流程我们就直接上代码了
1、前端安装vue-simple-uploader
npm install vue-simple-uploader@next --save
2、在main.ts引入
import uploader from 'vue-simple-uploader'
import 'vue-simple-uploader/dist/style.css';
// ...
const app = createApp(App)
// ...
// 引入上传组件
app.use(uploader)
3、前端组件全部代码
<template>
<uploader
ref="uploaderRef"
:options="options"
:auto-start="false"
:fileStatusText="fileStatusText"
class="uploader-container"
@file-added="onFileAdded"
@file-progress="onFileProgress"
@file-success="onFileSuccess"
@file-error="onFileError"
@file-removed="onDelete"
>
<uploader-unsupport>您的浏览器不支持上传组件</uploader-unsupport>
<uploader-drop>
<uploader-btn :attrs="attrs">{{deProps.btnText}}</uploader-btn>
</uploader-drop>
<uploader-list>
<template #default="props">
<!-- 已上传的文件列表 -->
<div v-for="file in uploadFileList" :key="file.fileId" :file="file" class="raw-list">
<div class="file-title">{{ file.name }}</div>
<div class="file-size">{{ parseFloat(file.size / 1024 / 1024).toFixed(2) }} MB</div>
<div class="file-status">已上传</div>
<el-button size="mini" type="danger" @click="onDelete(uploadFileList, file)">删除文件</el-button>
</div>
<!-- 正在上传的文件列表 -->
<uploader-file v-for="file in props.fileList" :key="file.fileId" :file="file" :list="true" v-show="!file.completed" />
</template>
</uploader-list>
</uploader>
</template>
<script setup>
import axios from 'axios';
import { getAccessToken,getTenantId } from '@/utils/auth';
import { propTypes } from '@/utils/propTypes'
const token = getAccessToken();
import {config} from '@/config/axios/config';
const emit = defineEmits(['success','delete'])
const fileStatusText = {
success: '上传成功',
error: '上传失败',
uploading: '正在上传',
paused: '暂停上传',
waiting: '等待上传'
};
// 原来已上传过的文件列表
const uploadFileList = ref([]);
// const uploader = ref(null);
const currentFile = ref(null);
const isPaused = ref(false);
const deProps = defineProps({
btnText: propTypes.string.def('选择文件上传') ,
fileList: propTypes.array.def([]), // 原来已上传的文件列表
singleFile: propTypes.bool.def(true), // 是否单文件上传
})
uploadFileList.value = deProps.fileList;
/**
* 上传组件配置
*/
const options = {
target: config.base_url + '/infra/minio/upload', // 目标上传 URL
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
}, // 接口的定义, 根据实际情况而定
chunkSize: 5 * 1024 * 1024, // 分块大小
singleFile: deProps.singleFile, // 是否单文件上传
simultaneousUploads: 3, // 同时上传3个分片
forceChunkSize: true, // 是否强制所有的块都是小于等于 chunkSize 的值。默认是 false。
// fileParameterName: 'file', // 上传文件时文件的参数名,默认file
maxChunkRetries: 3, // 最大自动失败重试上传次数
testChunks: false, // 是否开启服务器分片校验
// 额外的请求参数
query: (file) => {
return {
uploadId: file.uploadId,
fileName: file.name,
totalChunks: file.chunks.length
};
},
// 处理请求参数, 将参数名字修改成接口需要的
processParams: (params, file, chunk) => {
params.chunkIndex = chunk.offset; // 分片索引
return params;
}
};
// 限制上传的文件类型
const attrs = {
accept: '.mp4,.png,.jpg,.txt,.pdf,.ppt,.pptx,.doc,docx,.xls,xlsx,.ofd,.zip,.rar',
};
const uploaderRef = ref(null);
/**
* 模版中禁止了自动上传(:auto-start="false")
*/
const onFileAdded = async (file) => {
currentFile.value = file;
isPaused.value = false;
try {
// 1. 初始化上传会话
const initResponse = await axios.post(
`${config.base_url}/infra/minio/init`,
{ fileName: file.name, fileSize: file.size },
{
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
},
}
);
if (initResponse.data.code === 0) {
file.uploadId = initResponse.data.data;
} else {
throw new Error(initResponse.data.msg);
}
// 2. 获取已上传分片列表
const chunksResponse = await axios.get(
`${config.base_url}/infra/minio/uploaded-parts/${file.uploadId}`,
{
params: { totalChunks: file.chunks.length },
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
},
},
);
if (chunksResponse.data.code === 0) {
// 设置已上传分片
file.uploadedChunks = chunksResponse.data.data || [];
}
// 开始上传
file.resume();
} catch (error) {
file.cancel();
console.error('初始化上传出错:', error);
}
};
// 上传进度事件
const onFileProgress = (rootFile, file, chunk) => {
// 使用 progress() 方法获取上传进度
const progress = Math.round(file.progress() * 100);
console.log(`上传进度: ${progress}%`);
};
// 文件上传成功
const onFileSuccess = async (rootFile, file, response) => {
try {
// 调用合并接口
const mergeResponse = await axios.post(config.base_url + '/infra/minio/merge', {
uploadId: file.uploadId,
fileName: file.name
},{
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
},
});
if (mergeResponse.data.code === 0) {
// 添加到已上传文件列表
uploadFileList.value.push({
fileId: file.uploadId,
name: file.name,
size: file.size,
url: mergeResponse.data.data
});
} else {
console.error('文件合并失败:', mergeResponse.data.msg);
}
console.log(uploadFileList.value)
emit('success', uploadFileList.value);
} catch (error) {
console.error('文件合并请求失败:', error);
} finally {
currentFile.value = null;
}
};
// 文件上传失败
const onFileError = (rootFile, file, message) => {
console.error('文件上传失败:', message);
currentFile.value = null;
};
// 暂停/继续上传
const togglePause = () => {
if (!currentFile.value) return;
if (isPaused.value) {
currentFile.value.resume();
} else {
currentFile.value.pause();
}
isPaused.value = !isPaused.value;
};
// 取消上传
const onCancel = async (file) => {
if (file.status === 'uploading') {
try {
// 调用后端取消接口
await axios.post(`${config.base_url}/infra/minio/cancel/${file.uploadId}`,{
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
},
});
file.cancel();
} catch (error) {
console.error('取消上传失败:', error);
}
} else {
file.cancel();
}
currentFile.value = null;
};
// 删除文件
const onDelete = async (list,file) => {
try {
// 调用后端删除接口
await axios.delete(`http://localhost:48080/admin-api/infra/minio/delete?path=${file.url}`,{
headers: {
'tenant-id': getTenantId(),
'Authorization': `Bearer ${token}`
},
});
// 从列表中移除
const index = list.findIndex(f => f.fileId === file.fileId);
if (index !== -1) {
list.splice(index, 1);
}
emit('delete', list);
} catch (error) {
console.error('删除文件失败:', error);
}
};
</script>
<style lang="scss" scoped>
.uploader-container {
border: 1px solid #eee;
border-radius: 4px;
padding: 15px;
}
.raw-list{
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
.file-title{
width: 30%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
4、使用组件
<upload-chunk
ref="uploadChunkRef"
btnText="上传视频"
:fileList="fileList"
@success="uploadChunkSuccess"
@delete="uploadChunkDelete" />
前端的代码就这么多,接下来看看后端。
1、添加minio依赖
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
2、创建3个请求实体类
public class FileChunkInitReqVO {
private String fileId;
private String fileName;
private Long fileSize;
}
public class FileChunkMergeReqVO {
private String uploadId;
private String fileName;
}
public class FileChunkUploadReqVO {
private String uploadId;
private String fileName;
private Integer chunkIndex;
private Integer totalChunks;
private MultipartFile file;
}
3、Controller类
/**
* 删除指定文件
*
* @param path 文件ID
* @return 响应状态
*/
@DeleteMapping("/delete")
@PermitAll
public CommonResult<String> deleteFile(@RequestParam String path) {
try {
fileService.deleteFile(path);
return CommonResult.success("File deleted successfully.");
} catch (IOException | NoSuchAlgorithmException | InvalidKeyException e) {
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Error deleting file: " + e.getMessage());
}
}
///初始化分片上传
@PostMapping("/init")
@PermitAll
public CommonResult<String> initUploadSession(@RequestBody FileChunkInitReqVO reqVO) {
try {
String uploadId = fileService.initUploadSession(reqVO.getFileName(), reqVO.getFileSize());
return CommonResult.success(uploadId);
} catch (Exception e) {
log.error("初始化上传会话失败", e);
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "初始化上传会话失败");
}
}
//上传文件分片
@PostMapping("/upload")
@PermitAll
public CommonResult<Boolean> uploadFilePart(@Validated FileChunkUploadReqVO reqVO) {
try {
boolean result = fileService.uploadFilePart(
reqVO.getUploadId(),
reqVO.getChunkIndex(),
reqVO.getTotalChunks(),
reqVO.getFile()
);
return CommonResult.success(result);
} catch (Exception e) {
log.error("上传分片失败", e);
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "上传分片失败");
}
}
//获取已上传分片列表
@GetMapping("/uploaded-parts/{uploadId}")
@PermitAll
public CommonResult<List<Integer>> getUploadedParts(
@PathVariable String uploadId,
@RequestParam int totalChunks) {
try {
List<Integer> uploadedParts = fileService.getUploadedParts(uploadId, totalChunks);
return CommonResult.success(uploadedParts);
} catch (Exception e) {
log.error("获取已上传分片失败", e);
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "获取已上传分片失败");
}
}
//合并文件分片
@PostMapping("/merge")
@PermitAll
public CommonResult<String> mergeFileParts(@RequestBody FileChunkMergeReqVO reqVO) {
try {
String fileUrl = fileService.mergeFileParts(
reqVO.getUploadId(),
reqVO.getFileName()
);
return CommonResult.success(fileUrl);
} catch (Exception e) {
log.error("合并文件失败", e);
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "合并文件失败");
}
}
//取消上传
@PostMapping("/cancel/{uploadId}")
@PermitAll
public CommonResult<Boolean> cancelUpload(@PathVariable String uploadId) {
try {
fileService.cancelUpload(uploadId);
return CommonResult.success(true);
} catch (Exception e) {
log.error("取消上传失败", e);
return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "取消上传失败");
}
}
5、service类
@Service
public class MinioChunkUploadService {
@Resource
private FileMapper fileMapper;
// 改成自己的
private static final String endpoint = "http://xxxx";
private static final String accessKey = "BDnZ1SS3Kq0pxxxxxx";
private static final String accessSecret = "MAdjW4rd0hXoZNrxxxxxxxx";
private static final String bucketName = "xxtigrixxx";
private static final String CHUNK_PREFIX = "chunks/";
private static final String MERGED_PREFIX = "merged/";
/**
* 创建 Minio 客户端
* @return
*/
private MinioClient createMinioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, accessSecret)
.build();
}
/**
* 删除指定文件
*
* @param path 文件路径
*/
public void deleteFile(String path) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
try {
String fileNames = MERGED_PREFIX + path;
// 删除文件
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(fileNames).build());
} catch (MinioException e) {
throw new IOException("Error deleting file: " + e.getMessage(), e);
}
}
// 新增方法:检查分片是否已存在
public boolean checkChunkExists(String fileId, int chunkIndex)
throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
try {
String objectName = fileId + "/chunk-" + chunkIndex;
minioClient.statObject(StatObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return true;
} catch (Exception e) {
throw new IOException("Error checking chunk existence: " + e.getMessage(), e);
}
}
// 新增方法:获取已上传分片列表
public List<Integer> getUploadedChunks(String fileId)
throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
List<Integer> uploadedChunks = new ArrayList<>();
// 列出所有分片
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(fileId + "/chunk-")
.build());
for (Result<Item> result : results) {
Item item = null;
try {
item = result.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
String objectName = item.objectName();
// 提取分片索引: fileId/chunk-123
String chunkStr = objectName.substring(objectName.lastIndexOf("-") + 1);
try {
int chunkIndex = Integer.parseInt(chunkStr);
uploadedChunks.add(chunkIndex);
} catch (NumberFormatException ignored) {
// 忽略无效分片名
}
}
return uploadedChunks;
}
/**
* 初始化上传会话
*/
public String initUploadSession(String fileName, long fileSize) {
// 生成唯一上传ID
return UUID.randomUUID().toString();
}
/**
* 上传文件分片
*/
public boolean uploadFilePart(String uploadId, int chunkIndex, int totalChunks, MultipartFile filePart)
throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {
// 构建分片对象名称
String objectName = CHUNK_PREFIX + uploadId + "/" + chunkIndex;
MinioClient minioClient = createMinioClient();
// 上传文件分片
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(filePart.getInputStream(), filePart.getSize(), -1)
.contentType(filePart.getContentType())
.build()
);
return true;
}
/**
* 获取已上传分片列表
*/
public List<Integer> getUploadedParts(String uploadId, int totalChunks)
throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {
List<Integer> uploadedParts = new ArrayList<>();
MinioClient minioClient = createMinioClient();
// 列出所有分片
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(CHUNK_PREFIX + uploadId + "/")
.build()
);
for (Result<Item> result : results) {
try {
Item item = result.get();
String objectName = item.objectName();
// 提取分片索引: chunks/uploadId/123
String chunkIndexStr = objectName.substring(objectName.lastIndexOf("/") + 1);
uploadedParts.add(Integer.parseInt(chunkIndexStr));
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
return uploadedParts;
}
/**
* 合并文件分片
*/
public String mergeFileParts(String uploadId, String fileName)
throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {
// 获取所有分片
List<String> partNames = new ArrayList<>();
List<ComposeSource> sources = new ArrayList<>();
MinioClient minioClient = createMinioClient();
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(CHUNK_PREFIX + uploadId + "/")
.build()
);
for (Result<Item> result : results) {
Item item = result.get();
partNames.add(item.objectName());
sources.add(
ComposeSource.builder()
.bucket(bucketName)
.object(item.objectName())
.build()
);
}
// 按分片索引排序
sources.sort((a, b) -> {
int indexA = Integer.parseInt(a.object().substring(a.object().lastIndexOf("/") + 1));
int indexB = Integer.parseInt(b.object().substring(b.object().lastIndexOf("/") + 1));
return Integer.compare(indexA, indexB);
});
// 构建最终文件对象名称
String finalObjectName = MERGED_PREFIX + DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uploadId + "/" + fileName;
// 合并文件
minioClient.composeObject(
ComposeObjectArgs.builder()
.bucket(bucketName)
.object(finalObjectName)
.sources(sources)
.build()
);
// 删除分片文件
for (String partName : partNames) {
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(partName)
.build()
);
}
// 返回文件访问URL
return finalObjectName;
}
/**
* 取消上传
*/
public void cancelUpload(String uploadId)
throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {
MinioClient minioClient = createMinioClient();
// 删除所有分片
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(CHUNK_PREFIX + uploadId + "/")
.build()
);
for (Result<Item> result : results) {
Item item = result.get();
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(item.objectName())
.build()
);
}
}
}
OK,全部代码完成,有问题或哪里不对的地方欢迎指正
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐



所有评论(0)