一、準備工作
安裝 Minio 服務后,在 SpringBoot 項目中添加依賴:
<!-- MinIO --><dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.2.1</version></dependency>
在代碼中獲取 MinioClient(用于操作 Minio 的服務端):
MinioClient client = MinioClient.builder().endpoint("http://192.168.xx.133:9000") // 服務端IP+端口.credentials(minioProperties.getAccessKey(), // 服務端用戶名minioProperties.getSecretKey()) // 服務端密碼.build();
二、實現分片上傳+斷點續傳
2.1 思路
分片上傳和斷點續傳的實現過程中,需要在Minio內部記錄已上傳的分片文件。
這些分片文件將以文件md5作為父目錄,分片文件的名字按照01,02,...
的順序進行命名。同時,還必須知道當前文件的分片總數,這樣就能夠根據總數來判斷文件是否上傳完畢了。
比如,一個文件被分成了10片,所以總數是10。當前端發起上傳請求時,把一個個文件分片依次上傳,Minio 服務器中存儲的臨時文件依次是01
、02
、03
等等。
假設前端把05
分片上傳完畢了之后斷開了連接,由于 Minio 服務器仍然存儲著01
~05
的分片文件,因此前端再次上傳文件時,只需從06
序號開始上傳分片,而不用從頭開始傳輸。這就是所謂的斷點續傳。
2.2 代碼
① 分片上傳API
為了實現以上思路,考慮實現一個方法,用于上傳文件的某一個分片。
/*** 將文件進行分片上傳* <p>有一個未處理的bug(雖然概率很低很低):</p>* 當兩個線程同時上傳md5相同的文件時,由于兩者會定位到同一個桶的同一個臨時目錄,兩個線程會相互產生影響!* * @param file 分片文件* @param currIndex 當前文件的分片索引* @param totalPieces 切片總數(對于同一個文件,請確保切片總數始終不變)* @param md5 整體文件MD5* @return 剩余未上傳的文件索引集合*/public FragResult uploadFileFragment(MultipartFile file,Integer currIndex, Integer totalPieces, String md5) throws Exception {checkNull(currIndex, totalPieces, md5);// 臨時文件存放桶if ( !this.bucketExists(DEFAULT_TEMP_BUCKET_NAME) ) {this.createBucket(DEFAULT_TEMP_BUCKET_NAME);}// 得到已上傳的文件索引Iterable<Result<Item>> results = this.getFilesByPrefix(DEFAULT_TEMP_BUCKET_NAME, md5.concat("/"), false);Set<Integer> savedIndex = Sets.newHashSet();boolean fileExists = false;for (Result<Item> item : results) {Integer idx = Integer.valueOf( getContentAfterSlash(item.get().objectName()) );if (currIndex.equals( idx )) {fileExists = true;}savedIndex.add( idx );}// 得到未上傳的文件索引Set<Integer> remainIndex = Sets.newTreeSet();for (int i = 0; i < totalPieces; i++) {if ( !savedIndex.contains(i) ) {remainIndex.add(i);}}if (fileExists) {return new FragResult(false, remainIndex, "index [" + currIndex + "] exists");}this.uploadFileStream(DEFAULT_TEMP_BUCKET_NAME, this.getFileTempPath(md5, currIndex, totalPieces), file.getInputStream());// 還剩一個索引未上傳,當前上傳索引剛好是未上傳索引,上傳完當前索引后就完全結束了。if ( remainIndex.size() == 1 && remainIndex.contains(currIndex) ) {return new FragResult(true, null, "completed");}return new FragResult(false, remainIndex, "index [" + currIndex + "] has been uploaded");}
值得注意的是,我在項目中實踐該方法時,上述參數都是由前端傳來的,因此文件分片過程發生在前端,分片的大小也由前端定義。
② 合并文件API
當所有分片文件上傳完畢,需要手動調用 Minio 原生 API 來合并臨時文件(當然,在上面的那個方法中,當最后一個分片上傳完畢后直接執行合并操作也是可以的)
臨時文件合并完畢后,將會自動刪除所有臨時文件。
/*** 合并分片文件,并放到指定目錄* 前提是之前已把所有分片上傳完畢。* * @param bucketName 目標文件桶名* @param targetName 目標文件名(含完整路徑)* @param totalPieces 切片總數(對于同一個文件,請確保切片總數始終不變)* @param md5 文件md5* @return minio原生對象,記錄了文件上傳信息*/public boolean composeFileFragment(String bucketName, String targetName, Integer totalPieces, String md5) throws Exception {checkNull(bucketName, targetName, totalPieces, md5);// 檢查文件索引是否都上傳完畢Iterable<Result<Item>> results = this.getFilesByPrefix(DEFAULT_TEMP_BUCKET_NAME, md5.concat("/"), false);Set<String> savedIndex = Sets.newTreeSet();for (Result<Item> item : results) {savedIndex.add( item.get().objectName() );}if (savedIndex.size() == totalPieces) {// 文件路徑 轉 文件合并對象List<ComposeSource> sourceObjectList = savedIndex.stream().map(filePath -> ComposeSource.builder().bucket(DEFAULT_TEMP_BUCKET_NAME).object( filePath ).build()).collect(Collectors.toList());ObjectWriteResponse objectWriteResponse = client.composeObject(ComposeObjectArgs.builder().bucket(bucketName).object(targetName).sources(sourceObjectList).build());// 上傳成功,則刪除所有的臨時分片文件List<String> filePaths = Stream.iterate(0, i -> ++i).limit(totalPieces).map(i -> this.getFileTempPath(md5, i, totalPieces) ).collect(Collectors.toList());Iterable<Result<DeleteError>> deleteResults = this.removeFiles(DEFAULT_TEMP_BUCKET_NAME, filePaths);// 遍歷錯誤集合(無元素則成功)for (Result<DeleteError> result : deleteResults) {DeleteError error = result.get();System.err.printf("[Bigfile] 分片'%s'刪除失敗! 錯誤信息: %s", error.objectName(), error.message());}return true;}throw new GlobalException("The fragment index is not complete. Please check parameters [totalPieces] or [md5]");}
以上方法的源碼我放到了https://github.com/sky-boom/minio-spring-boot-starter,對原生的 Minio API 進行了封裝,抽取成了minio-spring-boot-starter
組件,感興趣的朋友歡迎前去查看。
2.3 后端調用API示例
這里以單線程的分片上傳為例(即前端每次只上傳一個分片文件,調用分片上傳接口后,接口返回下一個分片文件的序號)
① Controller 層
/*** 分片上傳* @param user 用戶對象* @param fileAddDto file: 分片文件, * currIndex: 當前分片索引, * totalPieces: 分片總數,* md5: 文件md5* @return 前端需上傳的下一個分片序號(-1表示上傳完成)*/@PostMapping("/file/big/upload")public ResultData<String> uploadBigFile(User user, BigFileAddDto fileAddDto) {// 1.文件為空,返回失敗 (一般不是用戶的問題)if (fileAddDto.getFile() == null) {throw new GlobalException();}// 2.名字為空,或包含特殊字符,則提示錯誤String fileName = fileAddDto.getFile().getOriginalFilename();if (StringUtils.isEmpty(fileName) || fileName.matches(FileSysConstant.NAME_EXCEPT_SYMBOL)) {throw new GlobalException(ResultCode.INCORRECT_FILE_NAME);}// 3. 執行分片上傳String result = fileSystemService.uploadBigFile(user, fileAddDto);return GlobalResult.success(result);}
② Service 層
@Overridepublic String uploadBigFile(User user, BigFileAddDto fileAddDto) {try {MultipartFile file = fileAddDto.getFile();Integer currIndex = fileAddDto.getCurrIndex();Integer totalPieces = fileAddDto.getTotalPieces();String md5 = fileAddDto.getMd5();log.info("[Bigfile] 上傳文件md5: {} ,分片索引: {}", md5, currIndex);FragResult fragResult = minioUtils.uploadFileFragment(file, currIndex, totalPieces, md5);// 分片全部上傳完畢if ( fragResult.isAllCompleted() ) {FileInfo fileInfo = getFileInfo(fileAddDto, user.getId());DBUtils.checkOperation( fileSystemMapper.insertFile(fileInfo) );String realPath = generateRealPath(generateVirtPath(fileAddDto.getParentPath(), file.getOriginalFilename()));// 發起文件合并請求, 無異常則成功minioUtils.composeFileFragment(getBucketByUsername(user.getUsername()), realPath, totalPieces, md5);return "-1";} else {Iterator<Integer> iterator = fragResult.getRemainIndex().iterator();if (iterator.hasNext()) {String nextIndex = iterator.next().toString();log.info("[BigFile] 下一個需上傳的文件索引是:{}", nextIndex);return nextIndex;}}} catch (Exception e) {e.printStackTrace();}log.error("[Bigfile] 上傳文件時出現異常");throw new GlobalException(ResultCode.FILE_UPLOAD_ERROR);}
2.4 前端
前端主要負責:
- 規定文件分片的大小(比如5M),然后把文件進行拆分。
- 計算文件分片的總數,并按序號把分片文件依次傳遞給后端。
- 前端每上傳完一個分片文件,接口都會返回下一個需要上傳的分片文件。此時前端把對應的分片文件繼續上傳即可。
- 當接口返回“-1”,表示所有文件已上傳完畢。
前端代碼此處不展示,有緣后續再花時間補充吧………………