最近在寫視頻課程的上傳,需要上傳的視頻幾百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><uploaderref="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', // 目標上傳 URLheaders: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`}, // 接口的定義, 根據實際情況而定chunkSize: 5 * 1024 * 1024, // 分塊大小singleFile: deProps.singleFile, // 是否單文件上傳simultaneousUploads: 3, // 同時上傳3個分片forceChunkSize: true, // 是否強制所有的塊都是小于等于 chunkSize 的值。默認是 false。// fileParameterName: 'file', // 上傳文件時文件的參數名,默認filemaxChunkRetries: 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 {@Resourceprivate 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-123String 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) {// 生成唯一上傳IDreturn 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/123String 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());}// 返回文件訪問URLreturn 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,全部代碼完成,有問題或哪里不對的地方歡迎指正