文件分片上传

前端流程

Main组件中,包含“上传”按钮,点击上传按钮,会调用“addFile”方法,该方法会将文件和文件父目录id传递出去,emit出去。因为上传文件后,会弹出弹框,显示文件上传进度及状态,这个弹框在父组件中,所以我们emit出去文件数据,让父组件可以拿到。

子组件:(Main.vue)

父组件:(framework.vue)

这里的const {file,filePid} = data; 就拿到了当时子组件Framework.vue中传来的文件和文件父目录id。可以看到144行又调用了一个uploaderRef中的addFile方法,并把文件参数传入,可以知道,这应该就是具体的文件上传逻辑方法。

捋一下下:emit()

拿上栗说明,在子组件(Main组件)中定义了emit事件:const emit = defineEmits(["addFile"]);

点击上传按钮后,会调用“addFile”方法,方法中emit了"addFile",触发了emit事件,接着,就到达了父组件(Framework组件)中的@addFile处,父组件定义了@addFile="addFile",于是就接着调用了父组件中的"addFile"方法。

Framework.vue引入一Uploader.vue作子组件,具体上传文件逻辑都在该子组件中。

父组件(Framework.vue)

这里的el-popover组件即是上文说到的文件上传后会弹出的弹框。

e补一下:emit和expose的区别:

emit是子组件先,父组件后,子组件有相应操作后,emit出去,触发父组件的操作。而expose是父组件先,子组件后,父组件调用子组件的。将子组件的方法或属性暴露出去,供父组件调用。

子组件(Uploader.vue)

子组件的addFile方法,父组件想调用,要子组件expose出去。之后,父组件可以通过 ref 访问子组件实例并调用暴露的属性和方法。(如上图Framework.vue中给了Uploader组件一个ref:uploaderRef,然后在js中通过uploaderRef.value.addFile()调用了子组件中的addFile方法)

前端文件上传具体逻辑

前端计算文件md5值,并将其作为一个参数传递。后端据此实现秒传逻辑。若文件md5值不存在,证明该文件首次传输,老实上传;若已存在,则直接从数据库拷贝一份,实现秒传。

addFile():

const addFile = async (file, filePid) => {
  const fileItem = {
    //文件,文件大小,文件流,文件名...
    file: file,
    //文件UID
    uid: file.uid,
    //md5进度
    md5Progress: 0,
    //md5值
    md5: null,
    //文件名
    fileName: file.name,
    //上传状态
    status: STATUS.init.value,
    //已上传文件大小
    uploadSize: 0,
    //文件总大小
    totalSize: file.size,
    //上传进度
    uploadProgress: 0,
    //暂停
    pause: false,
    //当前分片
    chunkIndex: 0,
    //文件父级ID
    filePid: filePid,
    //错误信息
    errorMsg: null,
  };
  //让fileItem在fileList最顶端
  //unshift():在数组头部添加内容
  fileList.value.unshift(fileItem);
   
  //文件为空
  if(fileItem.totalSize == 0){
    fileItem.status = STATUS.emptyfile.value;
    return;
  }

  //md5
  let md5FileUid = await computeMd5(fileItem);
  if(md5FileUid == null){
    return;
  } 

  //上传文件
  uploadFile(md5FileUid);
};

computeMd5 ():

//计算Md5
const computeMd5 = (fileItem) => {
  let file = fileItem.file;
  let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
  //分片数,向上取整
  let chunks = Math.ceil(file.size / chunkSize);
  //当前分片
  let currentChunk = 0;
  let spark = new SparkMD5.ArrayBuffer();
  let fileReader = new FileReader();

  let loadNext = () => {
    let start = currentChunk * chunkSize;   //开始节点
    //每个分片是5M,到最后一分片时,可能是小于5M的,这时如果再加5M,就会超出文件大小,报错
    //所以当开始节点加上分片大小大于文件大小时,结束节点就是文件大小了。
    let end = start + chunkSize >= file.size ? file.size : start + chunkSize; //结束节点
    //开读
    fileReader.readAsArrayBuffer(blobSlice.call(file,start,end));
  }

  loadNext();

  return new Promise((resolve,reject) => {
    let resultFile = getFileByUid(file.uid);
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      currentChunk++;
      if(currentChunk < chunks){  //不是最后一片
        console.log(
          `${file.name},第${currentChunk}分片解析完成,开始第${currentChunk + 1}`
        );
        //进度条
        let percent = Math.floor((currentChunk / chunks) * 100);
        resultFile.md5Progress = percent;
        //递归去跑
        loadNext();
      }else{  //最后一片
        let md5 = spark.end();  //拿到md5值
        spark.destroy();
        resultFile.md5Progress = 100; //进度拉满
        resultFile.status = STATUS.uploading.value; //解析完毕了,状态改为上传中。
        resultFile.md5 = md5; //设置md5值
        resolve(fileItem.uid);
      }
    };

    fileReader.onerror = () => {
      resultFile.md5Progress = -1;
      resultFile.status = STATUS.fail.value;
      resolve(fileItem.uid);
    }
  }).catch((error) => {
    return null;
  });
};

e补一下:Promise()

参数(resolve,reject):调用resolve()表示成功;调用reject()表示失败。

 addFile()中调用了computeMd5(),在computeMd5()中,计算出了文件的md5值,并赋值给了文件的ms5属性,最后返回了文件的id。在该方法执行时,页面上显示的是文件解析中,带解析进度条。接着调用uploadFile(),终于传到后端去了。

const emit = defineEmits(["uploadCallback"]);
//上传文件
//chunkIndex,分片下标,用于用户上传时暂停,再次上传,我们知道该从哪片开始上传
const uploadFile = async (uid,chunkIndex) => {
  //chunkIndex是否存在,不存在取0
  chunkIndex = chunkIndex ? chunkIndex : 0;
  //分片上传
  let currentFile = getFileByUid(uid);
  const file = currentFile.file;
  const fileSize = currentFile.totalSize;
  const chunks = Math.ceil(fileSize / chunkSize);
  for(let i = chunkIndex;i < chunks;i++){
    //在分片上传过程中,用户删除了该操作,就中止上传
    //具体操作是:拿当前文件id去删除列表找,如果找到了,就是用户删除了该操作
    //indexOf() 返回-1,证明没找到
    //注意:在某一分片上传时,删除,要等到该分片上传完,到下一分片才会中止操作。
    let delIndex = delList.value.indexOf(uid);
    if(delIndex != -1){ //找到了
      //删除
      delList.value.splice(delIndex,1);
      break;
    }
    currentFile = getFileByUid(uid);
    if(currentFile.pause){
      break;
    }

    let start = i * chunkSize;
    let end = start + chunkSize >= fileSize ? fileSize : start + chunkSize;
    let chunkFile = file.slice(start,end);
    
    let updateResult = await proxy.Request({
      url:api.upload,
      showLoading:false,
      dataType:"file",
      params:{
        file:chunkFile,
        fileName:file.name,
        fileMd5:currentFile.md5,
        chunkIndex:i,
        chunks:chunks,
        fileId:currentFile.fileId,
        filePid:currentFile.filePid,
      },
      showError:false,
      errorCallback:(errorMsg) => {
        currentFile.status = STATUS.fail.value;
        currentFile.errorMsg = errorMsg;
      },
      uploadProgressCallback:(event) => {
        let loaded = event.loaded;
        if(loaded > fileSize){
          loaded = fileSize;
        }
        currentFile.uploadSize = i * chunkSize + loaded;
        currentFile.uploadProgress = Math.floor((currentFile.uploadSize / fileSize) * 100);
      },
    });

    if(updateResult == null){
      break;
    }

    currentFile.fileId = updateResult.data.fileId;
    currentFile.status = STATUS[updateResult.data.status].value;
    currentFile.chunkIndex = i;
    if(updateResult.data.status == STATUS.upload_seconds.value ||
      updateResult.data.status == STATUS.upload_finish.value
    ){
      currentFile.uploadProgress = 100;
      emit("uploadCallback");
      break;
    }
  }
};

文件上传完后,触发emit事件“uploadCallback”,会到framework.vue父组件中调用该方法。

后端时间

后端先将所有分片存至一个临时目录,等所有分片上传完成后,合并为一个文件,放入存储地,并将文件信息入数据库,更新用户空间。 

/**
     * 文件分片上传
     *
     * @param webUserDto 用户信息
     * @param fileId     文件id
     * @param file       文件
     * @param fileName   文件名
     * @param filePid    文件父级目录
     * @param fileMd5    文件md5值,由前端传来
     * @param chunkIndex 分片顺序
     * @param chunks     总分片数
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public UploadResultDto uploadFile(SessionWebUserDto webUserDto, String fileId, MultipartFile file, String fileName, String filePid, String fileMd5, Integer chunkIndex, Integer chunks) {
        UploadResultDto resultDto = new UploadResultDto();
        Boolean uploadSuccess = true;
        File tempFileFolder = null;
        try {
            if (StringTools.isEmpty(fileId)) {
                fileId = StringTools.getRandomString(Constants.LENGTH_10);
            }
            resultDto.setFileId(fileId);
            Date curDate = new Date();
            UserSpaceDto spaceDto = redisComponent.getUserSpaceUse(webUserDto.getUserId());
            //若当前是第一分片,需要判断md5值,如果数据库中存在,证明数据库里有这个文件,直接在数据库里copy一份,实现秒传
            //若没有,则需要上传,设置md5值。
            if (chunkIndex == 0) {
                FileInfoQuery infoQuery = new FileInfoQuery();
                infoQuery.setFileMd5(fileMd5);
                infoQuery.setSimplePage(new SimplePage(0, 1));    //查一条
                infoQuery.setStatus(FileStatusEnums.USING.getStatus());
                //查询数据库里是否有这个文件
                List<FileInfo> dbFileList = this.fileInfoMapper.selectList(infoQuery);
                //如果存在,不用再次上传,实现秒传,直接在数据库里copy一份给这个用户
                //秒传
                if (!dbFileList.isEmpty()) {
                    FileInfo dbFile = dbFileList.get(0);
                    //判断文件大小,若超过,不给传
                    if (dbFile.getFileSize() + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
                        throw new BusinessException(ResponseCodeEnum.CODE_904);
                    }
                    dbFile.setFileId(fileId);
                    dbFile.setFilePid(filePid);
                    dbFile.setUserId(webUserDto.getUserId());
                    dbFile.setCreateTime(curDate);
                    dbFile.setLastUpdateTime(curDate);
                    dbFile.setStatus(FileStatusEnums.USING.getStatus());
                    dbFile.setDelFlag(FileDelFlagEnums.USING.getFlag());
                    //文件名
                    fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
                    dbFile.setFileName(fileName);
                    this.fileInfoMapper.insert(dbFile);
                    resultDto.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());
                    //更新用户使用空间
                    updateUserSpace(webUserDto, dbFile.getFileSize());
                    return resultDto;
                }
            }
            //判断磁盘空间
            Long currentTempSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
            if (file.getSize() + currentTempSize + spaceDto.getUseSpace() > spaceDto.getTotalSpace()) {
                throw new BusinessException(ResponseCodeEnum.CODE_904);
            }
            //暂存临时目录
            String tempFolderName = appConfig.getProjectFolder() + Constants.FILE_FOLDER_TEMP;
            String currentUserFolderName = webUserDto.getUserId() + fileId;
            tempFileFolder = new File(tempFolderName + currentUserFolderName);
            if (!tempFileFolder.exists()) {
                tempFileFolder.mkdirs();
            }
            File newFile = new File(tempFileFolder + "/" + chunkIndex);
            file.transferTo(newFile);
            //保存临时大小
            redisComponent.saveFileTempSize(webUserDto.getUserId(), fileId, file.getSize());
            if (chunkIndex < chunks - 1) {  //不是最后分片
                resultDto.setStatus(UploadStatusEnums.UPLOADING.getCode());
               
                return resultDto;
            }
           
            //最后一个分片上传完成,记录数据库,异步合并分片
            String month = DateUtils.format(new Date(), DateTimePatternEnum.YYYYMM.getPattern());
            String fileSuffix = StringTools.getFileSuffix(fileName);
            //真实文件名
            String realFileName = currentUserFolderName + fileSuffix;
            FileTypeEnums fileTypeEnums = FileTypeEnums.getFileTypeBySuffix(fileSuffix);
            //自动重命名
            fileName = autoRename(filePid, webUserDto.getUserId(), fileName);
            //入数据库
            FileInfo fileInfo = new FileInfo();
            fileInfo.setFileId(fileId);
            fileInfo.setUserId(webUserDto.getUserId());
            fileInfo.setFileMd5(fileMd5);
            fileInfo.setFileName(fileName);
            fileInfo.setFilePath(month + "/" + realFileName);
            fileInfo.setFilePid(filePid);
            fileInfo.setCreateTime(curDate);
            fileInfo.setLastUpdateTime(curDate);
            fileInfo.setFileCategory(fileTypeEnums.getCategory().getCategory());
            fileInfo.setFileType(fileTypeEnums.getType());
            fileInfo.setStatus(FileStatusEnums.TRANSFER.getStatus());
            fileInfo.setFolderType(FileFolderTypeEnums.FILE.getType());
            fileInfo.setDelFlag(FileDelFlagEnums.USING.getFlag());
            this.fileInfoMapper.insert(fileInfo);
            //该文件总空间
            Long totalSize = redisComponent.getFileTempSize(webUserDto.getUserId(), fileId);
            updateUserSpace(webUserDto, totalSize);
            resultDto.setStatus(UploadStatusEnums.UPLOAD_FINISH.getCode());
            //事务提交后,再调合并方法。
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    fileInfoService.transferFile(fileInfo.getFileId(), webUserDto);
                }
            });
            return resultDto;
        } catch (BusinessException e) {
            logger.error("文件上传失败", e);
            uploadSuccess = false;
            throw e;
        } catch (Exception e) {
            logger.error("文件上传失败", e);
            uploadSuccess = false;
        } finally {
            //如果有异常,删除当前临时目录
            if (!uploadSuccess) {
                try {
                    FileUtils.deleteDirectory(tempFileFolder);
                } catch (IOException e) {
                    logger.error("删除临时目录失败", e);
                }
            }
        }
        return resultDto;
    }

transferFile()调用union()进行合并。

合并分片方法:union()

 /**
     * 合并分片
     *
     * @param dirPath    分片存放临时目录
     * @param toFilePath 目标目录
     * @param fileName   文件名
     * @param delSource  是否删除源文件
     */
    private void union(String dirPath, String toFilePath, String fileName, Boolean delSource) {
        File dir = new File(dirPath);
        if (!dir.exists()) {
            throw new BusinessException("分片存放目录不存在");
        }
        File[] fileList = dir.listFiles();  //临时目录下的所有分片
        File targetFile = new File(toFilePath);
        RandomAccessFile writeFile = null;
        try {
            writeFile = new RandomAccessFile(targetFile, "rw");
            byte[] b = new byte[1024 * 10];
            for (int i = 0; i < fileList.length; i++) {
                int len = -1;
                File chunkFile = new File(dirPath + "/" + i);   //分片文件
                RandomAccessFile readFile = null;
                try {
                    readFile = new RandomAccessFile(chunkFile, "r");
                    while ((len = readFile.read(b)) != -1) {
                        writeFile.write(b, 0, len);
                    }
                } catch (Exception e) {
                    logger.error("合并分片失败", e);
                    throw new BusinessException("合并分片失败");
                } finally {
                    readFile.close();
                }

            }
        } catch (Exception e) {
            logger.error("合并文件:{}失败", fileName, e);
            throw new BusinessException("合并文件" + fileName + "出错了");
        } finally {
            if (writeFile != null) {
                try {
                    writeFile.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (delSource && dir.exists()) {
                try {
                    FileUtils.deleteDirectory(dir);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

大致结束了。此外,在transferFile()中还使用了ffmpeg工具生成视频文件和图片文件的缩略图,为在文件列表中展示小图。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值