Vue + ElementPlus + SpringBoot + Minio + AmazonS3 在Web端浏览器实现多线程分片上传解决方案,以及踩坑过程,未完善

概要

前段时间公司做了一个云盘的项目,已经测试应用过一份分片上传,但是效果不是很理想,在最近一期项目中,又涉及到了大文件的分片上传,重新整理了一份,找了很多方案,要不就是纯后端上传本地文件的,要不就是使用AmazonS3的分片上传,而AmazonS3的分片上传也是基于一整个文件的,用来做前后端联合的分片上传会产生很多问题,本套代码非最终版,但仅限容错补偿的地方,随时可以加入。

整体结构

后台管理内容用若依plus搭建的后台管理框架,但是和本文并没有太大关系,前端使用vue3,后端为springboot,文件服务器采用的minio,或者其他的文件服务器都差不多,由于若依集成的是AmazonS3的文件上传工具,以此为基本情况,处理文件上传的相关内容。

前置准备

minio的搭建教程网上有很多,就不细说了
具体jar aws1.12.540

            <dependency>
                <groupId>com.amazonaws</groupId>
                <artifactId>aws-java-sdk-s3</artifactId>
                <version>1.12.540</version>
            </dependency>

正式开始

方案一、分片文件直接上传至minio服务器,最后交由minio文件服务器进行合并,这种方案适用于大文件,但不一定适用于视频上传

基本情况

现在网上大部分的分片上传存在的问题,基本都是复制粘贴的,没差,而且大部分没办法进行前后端联合的分片上传,要不就先把文件上出纳到服务端,然后分片上传进文件服务器,我参考整理了以下的文档链接,这个是可以进行分片上传的,且代码完整
Minio分片上传、断点续传、分片下载、秒传、暂停(断点)下载(Java版-附源码地址)
你们参考这里也是可以的,这个方案也是可行的,接下来给出的是我的处理方案。

一、前端部分,封装组件 VideoUpload

出去element-plus之外,引入的包也就一个SparkMd5,用来计算文件MD5值

<template>
  <el-upload
    :file-list="fileList"
    :on-change="handleFileChange"
    :on-error="handleUploadError"
    :on-exceed="handleExceed"
    :on-success="handleUploadSuccess"
    :auto-upload="false"
    :show-file-list="false"
    :limit="limit"
    class="upload-file-uploader"
    ref="fileUpload"
    multiple
  >
    <el-button type="primary">选择视频文件</el-button>
  </el-upload>
  <!-- 文件列表 -->
  <transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
    <div :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
      <el-link :href="`${file.url}`" :underline="false" target="_blank">
        <span class="el-icon-document"> {
   {
    getFileName(file.name) }} </span>
      </el-link>
      <div class="ele-upload-list__item-content-action">
        <el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
      </div>
    </div>
  </transition-group>
  <el-progress
    v-if="progressShow"
    style="width: 100%;"
    :text-inside="true"
    :stroke-width="24"
    :percentage="progress"
    :format="(percentage) => (`${progressText}${percentage}%`)"
  />
</template>

<script setup>
import {
   ref} from 'vue';
import {
   chunkUploadFile, delOss, initChunkUpload, listByIds, overChunkUpload} from "@/api/system/oss";
import SparkMD5 from 'spark-md5';

// 定义相关变量
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const fileList = ref([]);
const chunkSize = 1024 * 1024 * 5; // 5MB per chunk
const currentChunk = ref(0);
const totalChunks = ref(0);
const currentFile = ref(null);
const loading = ref(false);
const progressShow = ref(false);
const isShow = ref(true);
const fileMd5 = ref(undefined);
const fileSize = ref(undefined);
const progress = ref(0);
const progressText = ref("");
const {
    proxy } = getCurrentInstance();

const props = defineProps({
   
  modelValue: [String, Object, Array],
  // 数量限制
  limit: {
   
    type: Number,
    default: 5,
  },
  // 大小限制(MB)
  fileSize: {
   
    type: Number,
    default: 5,
  },
  // 是否显示提示
  isShowTip: {
   
    type: Boolean,
    default: true
  }
});

watch(() => props.modelValue, async val => {
   
  if (val) {
   
    let temp = 1;
    // 首先将值转为数组
    let list;
    if (Array.isArray(val)) {
   
      list = val;
    } else {
   
      await listByIds(val).then(res => {
   
        list = res.data.map(oss => {
   
          oss = {
    name: oss.originalName, url: oss.url, ossId: oss.ossId };
          return oss;
        });
      })
    }
    // 然后将数组转为对象数组
    fileList.value = list.map(item => {
   
      item = {
   name: item.name, url: item.url, ossId: item.ossId};
      item.uid = item.uid || new Date().getTime() + temp++;
      return item;
    });
  } else {
   
    fileList.value = [];
    return [];
  }
},{
    deep: true, immediate: true });


// 最大并发数
const MAX_CONCURRENT_UPLOADS = 5;

// 文件变化处理
const handleFileChange = async (file, fileList) => {
   
  console.log('file', file);
  // 判断是否是 video 类型
  if (file?.raw?.type.startsWith('video/')) {
   
    currentFile.value = file.raw;
    totalChunks.value = Math.ceil(currentFile.value.size / chunkSize);
    currentChunk.value = 0;
    fileSize.value = file.size;
    fileMd5.value = undefined;
    if (totalChunks.value > 0) {
   
      await uploadChunks(currentFile.value);
    }
  } else {
   
    alert('请选择一个视频文件!');
  }
};

// 上传前文件验证
const beforeUpload = async (file) => {
   
  const isLt100M = file.size / 1024 / 1024 / 1024 < 4;
  if (!isLt100M) {
   
    alert('上传文件大小不能超过 2GB!');
    return false;
  }
  proxy.$modal.loading("正在上传文件,请稍候...");
  progressShow.value = true;
  progress.value = 0;
  progressText.value = "文件解析中";
  // 计算文件 MD5,返回一个同步等待的 Promise
  fileMd5.value = await getFileMd5(file)
  // 初始化分片上传
  number.value++;
  progress.value = 0;
  progressText.value = "文件上传中";
  let initResp = await initChunkUpload({
   chunks:totalChunks.value,name:file.name,fileSize:file.size,md5:fileMd5.value});
  return initResp.code === 200;
};

// 创建分片
const createChunk = (file, index) => {
   
  const start = index * chunkSize;
  const end = Math.min(file.size, start + chunkSize);
  return file.slice(start, end);
};

// 上传分片
const uploadChunk = async (file, index) => {
   
  const formData = new FormData();
  const chunk = createChunk(file, index);
  formData.append('file', chunk);
  formData.append('chunk', index + 1);
  formData.append('chunks', totalChunks.value);
  formData.append('name', file.name);
  formData.append('fileSize', file.size);
  formData.append('md5', fileMd5.value);

  try {
   
    const response = await chunkUploadFile(formData);
    if (response.code === 200) {
   
      currentChunk.value++;
      console.log(`分片 ${
     index + 1} 上传成功`);
    } else {
   
      console.error('文件分片上传失败');
    }
  } catch (error) {
   
    console.error('文件分片上传发生错误', error);
  }
};
// 控制并发上传分片
const uploadChunks = async (file) => {
   
  await beforeUpload(file);

  // 更新进度条的函数
  const updateProgress = () => {
   
    progress.value = ((currentChunk.value / totalChunks.value) * 100).toFixed(2); // 更新进度条的值
  };

  // 上传分片任务队列
  const uploadInQueue = async (index) => {
   
    await uploadChunk(file, index);
    updateProgress(); // 每上传一个分片更新进度条
  };

  // 创建一个控制并发的队列
  const executeUploadQueue = async () => {
   
    const promises = [];
    for (let i = currentChunk.value; i < totalChunks.value; i++) {
   
      // 如果正在上传的分片数达到最大限制,等待队列空闲
      if (promises.length >= MAX_CONCURRENT_UPLOADS) {
   
        // 等待最早的上传任务完成
        await Promise.race(promises);
      }

      // 开始上传当前分片
      const uploadPromise = uploadInQueue(i).finally(() => {
   
        // 每个分片上传完成后,移除该任务
        promises.splice(promises.indexOf(uploadPromise), 1);
      });

      // 将当前任务添加到队列中
      promises.push(uploadPromise);
    }

    // 等待所有任务完成
    await Promise.all(promises);
  };

  // 执行上传队列
  await executeUploadQueue();

  // 所有分片上传完后,执行后续操作
  let res = await overChunkUpload({
    md5: fileMd5.value });
  handleUploadSuccess(res, file);
  console.log('所有分片上传成功');
};


// 文件个数超出
function handleExceed () {
   
  proxy.$modal.msgError(`上传文件数量不能超过 ${
     props.limit} 个!`);
}
const getFileMd5 = (file) => {
   
  var fileReader = new FileReader();
  var blobSlice = File.prototype.mozSlice || File.prototype.webkitSlice || File.prototype.slice;
  var chunks = Math.ceil(file.size / chunkSize);
  var currentChunk = 0;
  var spark = new SparkMD5();

  // 初始化进度条
  return new Promise((resolve) => {
   
    fileReader.onload = function (e) {
   
      spark.appendBinary(e.target.result); // append binary string
      currentChunk++;

      // 更新进度条
      progress.value = ((currentChunk / chunks) * 100).toFixed(2); // 更新进度条的值

      if (currentChunk < chunks) {
   
        loadNext();
      } else {
   
        resolve(spark.end());
      }
    };

    fileReader.onerror = () => {
   
      console.error('文件读取失败,无法上传该文件');
    };

    function loadNext() {
   
      var start = currentChunk * chunkSize;
      var end = start + chunkSize >= file.size ? file.size : start + chunkSize;
      fileReader.readAsBinaryString(blobSlice.call(file, start, end));
    }

    loadNext();
  });
};


function handleDelete(index) {
   
  let ossId = fileList.value[index].ossId;
  delOss(ossId);
  fileList.value.splice(index, 1);
  emit("update:modelValue", listToString(fileList.value));
}


// 上传结束处理
function uploadedSuccessfully() {
   
  if (number.value > 0 && uploadList.value.length === number.value) {
   
    fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value);
    uploadList.value = [];
    number.value = 0;
    emit("update:modelValue", listToString(fileList.value));
    proxy.$modal.closeLoading();
  }
}

// 上传成功回调
function handleUploadSuccess
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值