大文件分片上传 ( 切片上传; 断点续传)

297 阅读3分钟

大文件分片上传 ( 切片上传; 断点续传)

由于浏览器和网络环境的限制,直接上传大文件可能导致失败或性能问题。通过分片上传可以将大文件分成小块逐步上传,并在服务器端合并

这其中的解决方案主要包括分片上传、断点续传和秒传等技术,这些技术可以有效解决大文件上传过程中遇到的问题。

大文件上传的常见问题及解决方案

  1. 分片上传‌:将大文件分成多个小文件进行上传,可以缩短每个请求的时间,并且如果某个请求失败,不需要重新上传整个文件。前端可以通过获取文件的专属MD5或其他唯一编码来确保文件的完整性。‌1
  2. 断点续传‌:在上传过程中,如果遇到中断,可以从上次中断的地方继续传输,避免重新上传整个文件。这可以通过记录已经上传的分片来实现。‌
  3. 秒传‌:对于已经上传过的文件,无需再次上传,直接使用已经存储在服务器上的版本。这可以通过比较文件的MD5码来实现。

大文件上传的实现步骤和技术细节

  1. 前端实现‌:在前端,可以使用JavaScript或相关库(如Web Uploaders)来分割文件并上传每个分片。通过获取文件的二进制内容,将其拆分成指定大小的切片,然后逐个上传。‌2
  2. 后端实现‌:在后端,接收到所有分片后,将这些分片重新组合成一个完整的文件。这通常通过记录每个分片的上传状态和顺序来实现。

本文给出的是Vue+ElementUI的示例, 其他框架可参照本文的代码逻辑适配即可

代码流程图:

slice_upload_flow.png

Template部分
<el-form
  ref="form"
  :rules="rules"
  :model="form"
  label-width="100px"
  label-position="right"
  style="width: 380px"
  class="components-upload"
>
  <el-form-item label="选择文件" required>
    <el-upload
      ref="upload"
      class="upload-demo"
      action
      accept=".zip"
      multiple
      :file-list="fileList"
      :show-file-list="true"
      :auto-upload="false"
      :on-error="uploadError"
      :on-change="uploadFileChange"
      :on-remove="handleRemoveFile"
      :http-request="httpRequest"
    >
      <el-button>
        <svg-icon icon-class="upload" />
      </el-button>
    </el-upload>
	<!--进度条-->
    <el-row v-if="uploadProgress > 0 && !!uploadLoading" :gutter="10">
      <el-col :span="24">
        <el-progress
          :text-inside="true"
          :stroke-width="14"
          :percentage="uploadProgress"
          :color="customColors"
        />
        <span v-if="errMsg" style="color: red">*{{ errMsg }}</span>
      </el-col>
    </el-row>
  </el-form-item>
</el-form>
Script部分
import { transitionSha256 } from '@/utils/sha256';
import { bindFileToBusiness } from '@/api/business';
import { getFileIsExists, getMultiPartV2, mergeFile, uploadPartFile } from '@/api/file';
import { getFileType } from '@/utils';

export default {
  name: 'UploadComponent',
  props: {
    isShow: {
      type: Boolean,
      default: false
    },
    softwareVersionId: {
      type: Number,
      default: null
    }
  },
  data() {
    return {
      fileList: [],
      fileSha256: '',
      uploadLoading: false,
      uploadProgress: 0,
      customColors: '',
      errMsg: '',
      successCount: 0,
      unUploadFileNum: 0,
      fileInfo: null,
      form: {}
    };
  },
  computed: {
    dialogVisible: {
      get() {
        return this.isShow;
      },
      set() {
        return this.isShow;
      }
    }
  },
  watch: {
    uploadProgress: {
      handler(val) {
        if (val < 50) {
          this.customColors = '#f1d702';
        } else if (val < 80) {
          this.customColors = '#f68104';
        } else {
          this.customColors = '#0cf804';
        }
      }
    }
  },
  methods: {
    /**
     * 提交上传
     */
    submitUpload() {
      if (this.fileList.length === 0) {
        this.$message.error(this.$t('errMsg.mustUploadFile'));
        return;
      }
      this.$refs.form.validate((valid) => {
        if (valid) {
          this.$refs.upload.submit();
        }
      });
    },
    /**
     * 上传控件变更
     * @param file
     * @param fileList
     */
    uploadFileChange(file, fileList) {
      this.fileList = [file];
    },
    /**
     * 移除文件
     * @param file
     * @param fileList
     */
    handleRemoveFile(file, fileList) {
      this.fileList = fileList;
      this.$refs.upload.clearFiles();
    },
    /**
     * 手动触发上传后调用
     * @param param 文件信息
     */
    httpRequest(param) {
      this.uploadLoading = true;
      this.fileInfo = param.file; // 相当于input里取得的files
      this.uploadProgress = 5;

      if (!this.validateFile(this.fileInfo)) {
        this.uploadProgress = 0;
        this.uploadLoading = false;
        return;
      }

      this.checkFileSha256(this.fileInfo)
        .then((res) => {
          if (!res) {
            this.bindFile()
              .then(() => {
                this.uploadProgress = 100;
                this.uploadLoading = false;
              })
              .catch((err) => {
                console.log(err);
                this.uploadLoading = false;
              });
          } else {
            const unUploadPartList = res.list.map((sha) => {
              return {
                id: res.id,
                partNum: sha.partNum,
                sha256: sha.sha256,
                blob: sha.blob
              };
            });
            this.unUploadFileNum = unUploadPartList.length;
            this.successCount = 0;
            Promise.all(
              this._.range(10).map((n) => this.uploadPartFile(unUploadPartList))
            )
              .then(() => {
                mergeFile(res.id).then(() => {
                  this.bindFile()
                    .then(() => {
                      this.uploadLoading = false;
                    })
                    .catch((err) => {
                      console.log(err);
                      this.uploadLoading = false;
                    });
                }).catch(err => {
                  console.log(err);
                  this.uploadLoading = false;
                  this.$message.error(this.$t('common.uploadFailed'));
                });
              })
              .catch((err) => {
                console.log(err);
                this.uploadLoading = false;
                this.$message.error(this.$t('common.uploadFailed'));
              });
          }
        })
        .catch((err) => {
          console.log(err);
          this.uploadLoading = false;
        });
    },
    /**
     * 校验文件合法性
     * @param file
     * @returns {boolean}
     */
    validateFile(file) {
      if (getFileType(file.name) !== 'zip') {
        this.$message.error(this.$t('errMsg.mustUploadZip'));
        return false;
      }
      if (file.size > 4 * 1024 * 1024 * 1024) {
        this.$message.error(this.$t('errMsg.fileMustLess', { fileSize: '4G' }));
        return false;
      }
      return true;
    },

    /**
     * 上传分片文件
     */
    uploadPartFile(unUploadPartList, retryNum = 0) {
      return new Promise((resolve, reject) => {
        const part = unUploadPartList.shift();
        if (!part) {
          return resolve();
        }
        const file = new FormData();
        file.append('file', part.blob);
        file.append('id', part.id);
        file.append('partNum', part.partNum);
        uploadPartFile(file)
          .then(() => {
            this.successCount++;
            this.uploadProgress = Math.floor(
              20 + (this.successCount / this.unUploadFileNum) * 75
            );
            return resolve(this.uploadPartFile(unUploadPartList, retryNum));
          })
          .catch((err) => {
            console.log(err);
            if (retryNum <= 4) {
              unUploadPartList.unshift(part); // 将元素重新加入数组开头
              return resolve(this.uploadPartFile(unUploadPartList, retryNum + 1));
            }
            return reject(err);
          });
      });
    },
    /**
     * 上传成功后调用绑定接口将文件绑定到相关业务
     * @returns {Promise<unknown>}
     */
    bindFile() {
      const obj = {
        ...this.form,
        fileKey: this.fileSha256,
        name: this.fileInfo.name,
        size: this.fileInfo.size,
        status: 'SUCCESS'
      };

      return new Promise((resolve, reject) => {
        bindFileToBusiness(obj, this.softwareVersionId)
          .then(() => {
            this.$message.success(this.$t('common.uploadSuccessful'));
            this.uploadProgress = 100;
            this.handleClose(true);
            resolve();
          })
          .catch((err) => {
            this.errMsg = err.message;
            reject(err);
          });
      });
    },
    /**
     * 判断文件是否存在
     */
    checkFileSha256(file) {
      return new Promise((resolve, reject) => {
        transitionSha256(file)
          .then((sha256) => {
            this.fileSha256 = sha256;
            this.uploadProgress = 10;
            getFileIsExists(sha256)
              .then((res) => {
                const { data } = res;
                if (data) {
                  // 存在即返回false
                  resolve(false);
                } else {
                  // 如不存在则获取文件未上传的分片文件列表
                  const obj = {
                    fileName: file.name,
                    fileKey: sha256,
                    fileSize: file.size
                  };
                  this.getMultiPart(obj)
                    .then((part) => {
                      if (
                        part.unfinishPartNumList &&
                        part.unfinishPartNumList.length > 0
                      ) {
                        // 对文件进行切片并返回分片文件列表
                        this.sliceFile(file, part.partSize).then((parts) => {
                          //  未上传成功的文件分片
                          const unFinishPartList = this.checkPartsIsUpload(
                            parts,
                            part.unfinishPartNumList
                          );
                          // 获取所有未上传分片的sha256
                          const promise = unFinishPartList.map(async(item) => {
                            return await transitionSha256(item.blob).then(
                              (partSha) => {
                                item.sha256 = partSha;
                                return item;
                              }
                            );
                          });
                          this.uploadProgress = 15;
                          Promise.all(promise).then(() => {
                            this.uploadProgress = 15;
                            const obj = {
                              id: part.id,
                              list: unFinishPartList.map((item) => {
                                return {
                                  partNum: item.partNum,
                                  sha256: item.sha256,
                                  blob: item.blob
                                };
                              })
                            };
                            this.uploadProgress = 20;
                            resolve(obj);
                          });
                        });
                      } else {
                        resolve(false);
                      }
                    })
                    .catch((err) => {
                      console.log(err);
                      this.uploadLoading = false;
                    });
                }
              })
              .catch((err) => {
                console.log(err);
                this.uploadLoading = false;
              });
          })
          .catch((err) => {
            console.log(err);
            this.uploadLoading = false;
          });
      });
    },

    /**
     * 获取未上传切片列表
     */
    getMultiPart(obj) {
      return new Promise((resolve, reject) => {
        getMultiPartV2(obj)
          .then((res) => {
            const { data } = res;
            resolve(data);
          })
          .catch((err) => {
            reject(err);
          });
      });
    },

    /**
     * 对文件进行切片
     * @param file
     * @param partSize
     * @returns {Promise<unknown>}
     */
    sliceFile(file, partSize = 100 * 1024 * 1024) {
      return new Promise((resolve) => {
        if (file.size <= 0) {
          resolve([]);
          return;
        }

        const fileList = [];
        const totalParts = Math.ceil(file.size / partSize);

        for (let i = 0; i < totalParts; i++) {
          const start = i * partSize;
          const end = Math.min(start + partSize, file.size);
          fileList.push({
            start,
            end,
            partNum: i + 1,
            blob: file.slice(start, end),
          });
        }

        resolve(fileList);
      });
    },
    /**
     * 上传失败
     * @param res
     * @param file
     */
    uploadError(res, file) {
      this.$message.error({
        title: this.$t('table.fail'),
        message: res.message,
        duration: 5000
      });
    },
    /**
     * 检查未上传的分片
     * @param parts
     * @param unUploads
     * @returns {*[]}
     */
    checkPartsIsUpload(parts, unUploads) {
      const arr = [];
      unUploads.forEach((item) => {
        arr.push(parts[item - 1]);
      });
      return arr;
    }
  }
};
计算文件哈希值
/**
 * 计算文件SHA256值
 * @param file
 * @returns {Promise<unknown>}
 */
export function transitionSha256(file) {
  const fileSize = file.size;
  // 切割的每个文件流的大小
  const chunkSize = 100 * 1024 * 1024;
  let offset = 0;
  // 创建Sha256对象
  const sha256 = CryptoJS.algo.SHA256.create();
  // 创建FileReader对象
  const fileReader = new FileReader();
  return new Promise((resolve, reject) => {
    fileReader.onprogress = e => {
      // console.log(Math.floor(e.loaded / e.total * 100));
    };
    // fileReader.readAsArrayBuffer调用后触发onload事件
    fileReader.onload = (e) => {
      if (e.target.error === null) {
        offset += e.loaded;
        const wordArray = CryptoJS.lib.WordArray.create(fileReader.result);
        // 将切割的文件流暂存
        sha256.update(wordArray);
      } else {
        reject(e.target.error);
      }

      if (offset < fileSize) {
        fileReader.readAsArrayBuffer(file.slice(offset, chunkSize + offset));
      } else {
        // 监测加密完成后返回文件的Sha256值
        return resolve(sha256.finalize().toString());
      }
    };
    fileReader.readAsArrayBuffer(file?.slice(offset, chunkSize + offset));
  });
}