SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

14 篇文章 1 订阅
8 篇文章 0 订阅

SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

  • SpringBoot 大文件基于md5实现分片上传、断点续传、秒传
  • 前言
  • 1. 基本概念
    • 1.1 分片上传
    • 1.2 断点续传
    • 1.3 秒传
    • 1.4 分片上传的实现
  • 2. 分片上传前端实现
    • 2.1 什么是WebUploader?
      • 功能特点
      • 接口说明
      • 事件API
      • Hook 机制
    • 2.2 前端代码实现
      • 2.2.1 模块引入
      • 2.2.2 核心代码
        • 核心分片组件:WebUpload.vue
        • 引用组件:App.vue
      • 2.2.3 项目结构和运行效果
  • 3 .分片上传后端实现
    • 3.1 项目结构和技术介绍
    • 3.2 核心代码
      • 控制类:FileUploadController.java
      • 核心实现方法:FileZoneRecordServiceImpl.java
  • 4. 项目运行测试
    • 4.1 测试效果
    • 4.2 数据库记录
    • 4.3 上传目录文件
    • 4.4 网络访问上传的文件
  • 5. 项目源码
  • 6.参考链接

SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

阅读说明:

  • 本文适用于有初级后端开发基础或者初级前端开发者的人群
  • 如果不想看相关技术介绍,可以直接跳转到第2,3章节,可运行项目的前后端源码在文末
  • 后端地址: git clone https://gitee.com/zhouquanstudy/springboot-file-chunk-md5.git
  • 前端地址: git clone https://gitee.com/zhouquanstudy/file-chunk-upload-md5.git

如有疑问或者错误之处,敬请指正

前言

在项目开发中需要上传非常大的文件时,单次上传整个文件往往会遇到网络不稳定、带宽限制、上传失败等问题。为了解决这些问题,文件分片上传(也称为断点续传)应运而生。本文将介绍大文件上传的基本概念及其在 SpringBoot 中的实现方法,包括分片上传、断点续传和秒传技术。效果图如下:

分片上传md5

1. 基本概念

1.1 分片上传

分片上传的核心思想是将一个大文件分成若干份大小相等的多个小块数据块(称为 Part)。所有小块文件上传成功后,再将其合并成完整的原始文件。

分片上传的优点:

  • 断点续传:在网络中断或其他错误导致上传失败时,只需重新上传失败的部分,而不必从头开始上传整个文件,从而提高上传的可靠性和效率。
  • 降低网络压力:分片上传可以控制每个片段的大小,避免一次性传输大量数据导致的网络拥堵,提高网络资源的利用率。
  • 并行上传:多个分片可以同时上传,加快整体上传速度。
  • 灵活处理:服务器可以更灵活地处理和存储文件分片,减少内存和带宽的占用。

1.2 断点续传

断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为划分为几个部分,每个部分采用一个线程进行上传或下载。如果遇到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而无需从头开始。

断点续传的实现过程:

  1. 前端将文件按百分比进行计算,每次上传文件的百分之一(文件分片),给文件分片编号。
  2. 后端将前端每次上传的文件放入缓存目录。
  3. 前端全部文件上传完毕后,发送合并请求。
  4. 后端使用 RandomAccessFile 进行多线程读取所有分片文件,一个线程一个分片。
  5. 后端每个线程按序号将分片文件写入目标文件中。
  6. 上传过程中发生断网或手动暂停,下次上传时发送续传请求,后端删除最后一个分片。
  7. 前端重新发送上次的文件分片。

1.3 秒传

文件上传中的“秒传”是一种优化文件上传过程的技术。其主要原理是通过文件的特征值(通常是文件的哈希值,如 MD5、SHA-1 或 SHA-256 等)来判断文件是否已经存在于服务器上,从而避免重复上传相同的文件。

秒传的具体流程:

  1. 计算文件哈希值:客户端在开始上传文件之前,计算文件的哈希值。
  2. 发送哈希值:客户端将计算得到的哈希值发送给服务器。
  3. 服务器校验:服务器根据收到的哈希值查询数据库或文件存储系统,判断是否已存在相同哈希值的文件。
    • 如果文件已存在:服务器直接返回文件已存在的信息,客户端即可认为上传完成,不需实际上传文件数据。
    • 如果文件不存在:服务器通知客户端继续上传文件数据。
  4. 上传文件数据:如果服务器通知文件不存在,客户端实际上传文件数据,服务器接收后存储并更新相应哈希值记录。

秒传的优点:

  • 节省带宽:避免重复上传相同的文件,特别是在大文件上传场景中效果显著。
  • 加快上传速度:用户体验更好,对于已存在的文件可以实现“秒传”。
  • 减轻服务器负担:减少不必要的数据传输和存储压力。

秒传技术广泛应用于网盘、云存储、文件共享平台等场景中。

1.4 分片上传的实现

在 SpringBoot 中,可以通过以下步骤实现分片上传:

2.1 前端实现

前端使用 WebUploader 等库实现分片上传。具体步骤如下:

  1. 使用 WebUploader 初始化上传组件,设置分片大小及其他参数。
  2. 在文件分片上传前,计算每个分片的哈希值并发送到服务器。
  3. 服务器验证分片的哈希值,返回是否需要上传该分片。
  4. 前端根据服务器返回结果,决定是否上传分片。

2.2 后端实现

后端可以使用 SpringBoot 提供的文件上传接口来处理分片上传请求。具体步骤如下:

  1. 接收并验证前端发送的分片文件及其哈希值。
  2. 将分片文件保存到临时目录。
  3. 保存分片文件信息(如序号、哈希值等)到数据库。
  4. 在接收到所有分片后,合并分片文件为完整文件。

2. 分片上传前端实现

技术栈或技术点:vue、webuploader、elmentui

2.1 什么是WebUploader?

WebUploader 是由百度公司开发的一个现代文件上传组件,主要基于 HTML5,同时辅以 Flash 技术。它支持大文件的分片上传,提高了上传效率,并且兼容主流浏览器。

官网地址: [Web Uploader - Web Uploader (fex-team.github.io)](http://fex.baidu.com/webuploader/)

image-20240608212651303

功能特点

  1. 分片、并发上传: WebUploader 支持将大文件分割成小片段并行上传,极大地提高了上传效率。
  2. 预览、压缩: 支持常用图片格式(如 jpg、jpeg、gif、bmp、png)的预览和压缩,节省了网络传输数据量。
  3. 多途径添加文件: 支持文件多选、类型过滤、拖拽(文件和文件夹)以及图片粘贴功能。
  4. HTML5 & FLASH: 兼容所有主流浏览器,接口一致,不需要担心内部实现细节。
  5. MD5 秒传: 通过 MD5 值验证,避免重复上传相同文件。
  6. 易扩展、可拆分: 采用模块化设计,各功能独立成小组件,可自由组合搭配。

接口说明

WebUploader 提供了丰富的接口和钩子函数,以下是几个关键的接口:

  • before-send-file: 在文件发送之前执行。
  • before-file: 在文件分片后、上传之前执行。
  • after-send-file: 在所有文件分片上传完毕且无错误时执行。

WebUploader 的所有代码都在一个闭包中,对外只暴露了一个变量 WebUploader,避免与其他框架冲突。所有内部类和功能都通过 WebUploader 命名空间进行访问。

事件API

Uploader 实例拥有类似 Backbone 的事件 API,可以通过 onoffoncetrigger 进行事件绑定和触发。

uploader.on('fileQueued', function(file) {
    // 处理文件加入队列的事件
});

 this.uploader.on('uploadSuccess', (file, response) => {
     // 上传成功事件
});

除了通过 on 绑定事件外,还可以直接在 Uploader 实例上添加事件处理函数:

uploader.onFileQueued = function(file) {
    // 处理文件加入队列的事件
};

Hook 机制

关于hook机制的个人理解:Hook机制就像是在程序中的特定事件或时刻(比如做地锅鸡的时候)设定一些“钩子”。当这些事件发生时,程序会去“钩子”上找有没有要执行的额外功能,然后把这些功能执行一下。这就好比在做地锅鸡的过程中,你可以在某个步骤(比如炖鸡的时候)加上自己的调料或额外的配菜,来调整和丰富最终的味道,而不需要改动整体的食谱。

Uploader 内部功能被拆分成多个小组件,通过命令机制进行通信。例如,当用户选择文件后,filepicker 组件会发送一个添加文件的请求,负责队列的组件会根据配置项处理文件并决定是否加入队列。

webUploader.Uploader.register(
  {
    'before-send-file': 'beforeSendFile',
    'before-send': 'beforeSend',
    'after-send-file': 'afterSendFile'
  },
  {
    // 时间点1:所有分块进行上传之前调用此函数
    beforeSendFile: function(file) {
      // 利用 md5File() 方法计算文件的唯一标记符
      // 创建一个 deferred 对象
      var deferred = webUploader.Deferred();
      // 计算文件的唯一标记,用于断点续传和秒传
      // 请求后台检查文件是否已存在,实现秒传功能
      return deferred.promise();
    },
    // 时间点2:如果有分块上传,则每个分块上传之前调用此函数
    beforeSend: function(block) {
      // 向后台发送当前文件的唯一标记
      // 请求后台检查当前分块是否已存在,实现断点续传功能
      var deferred = webUploader.Deferred();
      return deferred.promise();
    },
    // 时间点3:所有分块上传成功之后调用此函数
    afterSendFile: function(file) {
      // 前台通知后台合并文件
      // 请求后台合并所有分块文件
    }
  }
);

2.2 前端代码实现

2.2.1 模块引入

在已有项目或者新的空vue项目中先执行下列命令

# 引入分片需要
npm install webuploader
npm install jquery@1.12.4

image-20240608223139745

image-20240608223551207

2.2.2 核心代码

核心分片组件:WebUpload.vue
<template>
  <div class="center-container">
    <div class="container">
      <div class="handle-box">
        <el-button type="primary" id="extend-upload-chooseFile" icon="el-icon-upload2">
          选择文件
        </el-button>
        <div class="showMsg">支持上传的文件后缀:<span style="color: #f10808; font-size: 18px">{{
            options.fileType
          }}</span></div>
      </div>
      <el-table :data="fileList" style="width: 100%">
        <el-table-column prop="fileName" label="文件名称" align="center" width="180"></el-table-column>
        <el-table-column prop="fileSize" align="center" label="文件大小" width="180"></el-table-column>
        <el-table-column label="进度" align="center" width="300">
          <template slot-scope="scope">
            <div class="progress-container">
              <el-progress :text-inside="true" :stroke-width="15" :percentage="scope.row.percentage"></el-progress>
            </div>
          </template>
        </el-table-column>
        <el-table-column label="上传速度" align="center" width="150">
          <template slot-scope="scope">
            <div>{{ scope.row.speed }}</div>
          </template>
        </el-table-column>
        <el-table-column label="操作" align="center" fixed="right">
          <template slot-scope="scope">
            <el-button type="text" icon="el-icon-close" class="red" @click="removeRow(scope.$index, scope.row)">移除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script>
import $ from 'jquery'
import webUploader from 'webuploader'

export default {
  name: 'WebUpload',
  props: {
    headers: {
      type: String,
      default: ''
    },
    fileNumLimit: {
      type: Number,
      default: 100
    },
    fileSize: {
      type: Number,
      default: 100 * 1024 * 1024 * 1024
    },
    chunkSize: {
      type: Number,
      default: 1 * 1024 * 1024
    },
    uploadSuffixUrl: {
      type: String,
      default: 'http://localhost:8810'
    },
    options: {
      default: function () {
        return {
          fileType: 'doc,docx,pdf,xls,xlsx,ppt,pptx,gif,jpg,jpeg,bmp,png,rar,zip,mp4,avi',
          fileUploadUrl: '/v1/upload/zone/zoneUpload', //上传地址
          fileCheckUrl: '/v1/upload/zone/md5Check', //检测文件是否存在url
          checkChunkUrl: '/v1/upload/zone/md5Check', //检测分片url
          mergeChunksUrl: '/v1/upload/zone/merge', //合并文件请求地址 提交测试
          headers: {}
        }
      }
    },

    fileListData: {
      type: Array,
      default: function () {
        return []
      }
    }
  },
  data() {
    return {
      fileList: [], // 存储等待上传文件列表的数组
      percentage: 0, // 上传进度,初始化为0
      uploader: {}, // WebUploader实例对象
      uploadStatus: 'el-icon-upload', // 上传状态图标,默认为上传图标
      uploadStartTime: null, // 文件上传开始时间
      uploadedFiles: [] // 存储上传成功文件信息的数组
    }
  },
  mounted() {
    this.register()
    this.initUploader()
    this.initEvents()

    // 监视 fileListData 变化,并将其赋值给 fileList
    this.$watch('fileListData', (newVal) => {
      this.fileList = [...newVal];
    });

  },
  methods: {
    initUploader() {
      var fileType = this.options.fileType
      this.uploader = webUploader.create({
        // 不压缩image
        resize: false,
        // swf文件路径
        swf: '../../../assets/Uploader.swf', // swf文件路径 兼容ie的,可以不设置
        // 默认文件接收服务端。
        server: this.uploadSuffixUrl + this.options.fileUploadUrl,
        pick: {
          id: '#extend-upload-chooseFile', //指定选择文件的按钮容器
          multiple: false //开启文件多选,
        },
        accept: [
          {
            title: 'file',
            extensions: fileType,
            mimeTypes: this.buildFileType(fileType)
          }
        ],
        compressSize: 0,
        fileNumLimit: this.fileNumLimit,
        fileSizeLimit: 2 * 1024 * 1024 * 1024 * 1024,
        fileSingleSizeLimit: this.fileSize,
        chunked: true,
        threads: 10,
        chunkSize: this.chunkSize,
        prepareNextFile: false,
      })
    },

    register() {
      const that = this;
      const options = this.options;
      const uploadSuffixUrl = this.uploadSuffixUrl;
      const fileCheckUrl = uploadSuffixUrl + options.fileCheckUrl;
      const checkChunkUrl = uploadSuffixUrl + options.checkChunkUrl;
      const mergeChunksUrl = uploadSuffixUrl + options.mergeChunksUrl;

      webUploader.Uploader.register(
          {
            'before-send-file': 'beforeSendFile',
            'before-send': 'beforeSend',
            'after-send-file': 'afterSendFile'
          },
          {
            beforeSendFile: function (file) {
              const deferred = webUploader.Deferred();

              new webUploader.Uploader()
                  .md5File(file, 0, 10 * 1024 * 1024)
                  .progress(function () {
                  })
                  .then(function (val) {
                    file.fileMd5 = val

                    $.ajax({
                      type: 'POST',
                      url: fileCheckUrl,
                      data: {
                        checkType: 'FILE_EXISTS',
                        contentType: file.type,
                        zoneTotalMd5: val
                      },
                      dataType: 'json',
                      success: function (response) {
                        if (response.success) {
                          that.uploader.skipFile(file)
                          // 更新进度条
                          that.percentage = 1
                          that.$notify.success({

                            showClose: true,
                            message: `[ ${file.name} ]文件秒传`
                          })

                          that.uploadedFiles.push(response.data)
                          deferred.reject()
                        } else {
                          if (response.code === 30001) {
                            const m = response.message + ',文件后缀:' + file.ext;
                            that.uploader.skipFile(file)
                            that.setTableBtn(file.id, m)
                            that.uploadedFiles.push(response.data)
                            deferred.reject()
                          } else {
                            deferred.resolve()
                          }
                        }
                      }
                    })
                  })

              return deferred.promise()
            },
            beforeSend: function (block) {
              const deferred = webUploader.Deferred();

              new webUploader.Uploader()
                  .md5File(block.file, block.start, block.end)
                  .progress(function () {
                  })
                  .then(function (val) {
                    block.zoneMd5 = val
                    $.ajax({
                      type: 'POST',
                      url: checkChunkUrl,
                      data: {
                        checkType: 'ZONE_EXISTS',
                        zoneTotalMd5: block.file.fileMd5,
                        zoneMd5: block.zoneMd5
                      },
                      dataType: 'json',
                      success: function (response) {
                        if (response.success) {
                          deferred.reject()
                        } else {
                          deferred.resolve()
                        }
                      }
                    })
                  })
              return deferred.promise()
            },
            afterSendFile: function (file) {
              $.ajax({
                type: 'POST',
                url: mergeChunksUrl + "?totalMd5=" + file.fileMd5,
                dataType: 'JSON',
                success: function (res) {
                  if (res.success) {
                    const data = res.data.fileInfo;
                    that.uploader.skipFile(file)
                    // 更新进度条
                    that.percentage = 1
                    that.uploadedFiles.push(data)
                  }
                }

              })
            }
          }
      )
    },
    initEvents() {
      const that = this;
      const uploader = this.uploader;

      uploader.on('fileQueued', function (file) {
        // 清空现有文件列表,实现只上传单个文件
        if (!this.multiple) {
          this.fileList = []
          this.uploadedFiles = []
        }
        const fileSize = that.formatFileSize(file.size);
        const row = {
          fileId: file.id,
          fileName: file.name,
          fileSize: fileSize,
          validateMd5: '0%',
          progress: '等待上传',
          percentage: 0,
          speed: '0KB/s',
          state: '就绪'
        };
        that.fileList.push(row)
        that.uploadToServer()
      })

      this.uploader.on('uploadProgress', (file, percentage) => {
        // 找到对应文件并更新进度和速度
        let targetFile = this.fileList.find(item => item.fileId === file.id)
        if (targetFile) {
          // 计算上传速度
          const currentTime = new Date().getTime()
          const elapsedTime = (currentTime - (targetFile.startTime || currentTime)) / 1000 // 秒
          const uploadedSize = percentage * file.size
          const speed = this.formatFileSize(uploadedSize / elapsedTime) + '/s'
          // 更新文件信息
          targetFile.percentage = parseFloat((percentage * 100).toFixed(2))
          targetFile.speed = speed
          targetFile.startTime = targetFile.startTime || currentTime
        }
      })

      this.uploader.on('uploadSuccess', (file, response) => {
        this.uploadedFiles = []
        if (response.code === 10000) {
          response.data.fileName = response.data.originalName
          response.data.percentage = this.fileList[0].percentage
          response.data.fileSize = this.fileList[0].fileSize
          response.data.speed = this.fileList[0].speed
          this.uploadedFiles.push(response.data)
          // this.$message.success('上传完成')
        } else {
          this.$message.error('上传失败: ' + response.message)
        }
      })

      /**上传之前**/
      uploader.on('uploadBeforeSend', function (block, data, headers) {
        data.fileMd5 = block.file.fileMd5
        data.contentType = block.file.type
        data.chunks = block.file.chunks
        data.zoneTotalMd5 = block.file.fileMd5
        data.zoneMd5 = block.zoneMd5
        data.zoneTotalCount = block.chunks
        data.zoneNowIndex = block.chunk
        data.zoneTotalSize = block.total
        data.zoneStartSize = block.start
        data.zoneEndSize = block.end
        headers.Authorization = that.options.headers.Authorization
      })

      uploader.on('uploadFinished', function () {
        that.percentage = 1
        that.uploadStaus = 'el-icon-upload'
        that.$message.success({
          showClose: true,
          message: '文件上传完毕'
        })
      })
    },

    setTableBtn(fileId, showmsg, sid) {
      var fileList = this.fileList
      for (var i = 0; i < fileList.length; i++) {
        if (fileList[i].fileId == fileId) {
          this.fileList[i].progress = showmsg
          this.fileList[i].sid = sid || ''
        }
      }
    },
    removeRow(index, row) {
      this.fileList.splice(index, 1)
      this.removeFileFromUploaderQueue(row.fileId)
      this.$emit('removeRow', index, row)
    },

    removeFileFromUploaderQueue(fileId) {
      const files = this.uploader.getFiles()
      for (let i = 0; i < files.length; i++) {
        if (files[i].id === fileId) {
          this.uploader.removeFile(files[i], true)
          break
        }
      }
    },

    uploadToServer() {
      this.uploadStatus = 'el-icon-loading'
      this.uploadStartTime = new Date()
      this.uploader.upload()
    },

    clearFiles() {
      const that = this
      that.uploadStaus = 'el-icon-upload'
      that.uploader.reset()
      this.$emit('clearFiles', [])
    },
    buildFileType(fileType) {
      var ts = fileType.split(',')
      var ty = ''

      for (var i = 0; i < ts.length; i++) {
        ty = ty + '.' + ts[i] + ','
      }
      return ty.substring(0, ty.length - 1)
    },
    strIsNull(str) {
      if (typeof str == 'undefined' || str == null || str == '') {
        return true
      } else {
        return false
      }
    },
    formatFileSize(size) {
      var fileSize = 0
      if (size / 1024 > 1024) {
        var len = size / 1024 / 1024
        fileSize = len.toFixed(2) + 'MB'
      } else if (size / 1024 / 1024 > 1024) {
        len = size / 1024 / 1024
        fileSize = len.toFixed(2) + 'GB'
      } else {
        len = size / 1024
        fileSize = len.toFixed(2) + 'KB'
      }
      return fileSize
    }
  }
}
</script>
<style>
.center-container {
  transform: scale(1.1); /* 缩放整个容器 */
  margin-left: 300px;
  justify-content: center;
  align-items: center;
  height: 100vh; /* 让容器占满整个视口高度 */
}

.container {
  padding: 30px;
  border: 1px solid #312828;
  border-radius: 5px;
}

.handle-box {
  margin-bottom: 20px;
}

#picker div:nth-child(2) {
  width: 100% !important;
  height: 100% !important;
}


.webuploader-element-invisible {
  position: absolute !important;
  clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
  clip: rect(1px, 1px, 1px, 1px);
}

.webuploader-pick-hover {
  background: #409eff;
}

/* 统一设置 label 的字体大小 */
.el-table-column label {
  font-size: 30px;
}

.showMsg {
  margin: 5px;
  font-size: 16px;
}
</style>

引用组件:App.vue
<template>
  <div id="app">
    <main>
      <el-form :span="20">
        <el-col :span="20">
          <el-form-item>
            <!-- 分片上传组件 -->
            <WebUpload></WebUpload>
          </el-form-item>
        </el-col>
      </el-form>
    </main>
  </div>
</template>

<script>
import WebUpload from './components/WebUpload.vue'

export default {
  name: 'App',
  components: {
    WebUpload
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

同时使用了样式,因此需要引入element-ui

npm install element-ui -S

# main.js中内容
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';

Vue.use(ElementUI);

new Vue({
  el: '#app',
  render: h => h(App)
});

2.2.3 项目结构和运行效果

执行npm run sever运行后页面效果和最终项目代码结构

image-20240614075652044

3 .分片上传后端实现

3.1 项目结构和技术介绍

本项目的后端采用Spring Boot框架,结合MyBatis-Plus以提高数据库操作的效率。数据库使用MySQL,提供高性能和可靠性。这些技术的组合确保了系统的稳定性和高效性,并简化了开发和维护过程

image-20240614080027271

3.2 核心代码

控制类:FileUploadController.java

FileUploadController类负责处理文件上传相关的操作。其主要功能包括:

  1. 大文件分片上传:处理前端分片上传的大文件请求,接收并记录文件片段信息。
  2. MD5校验:校验文件或分片的MD5值,检查文件或分片是否已经存在,以避免重复上传。
  3. 文件合并:在所有分片上传完成后,将所有分片合并成一个完整的文件。
package com.example.zhou.controller;

import com.example.zhou.common.Result;
import com.example.zhou.common.ResultCode;
import com.example.zhou.entity.ArchiveZoneRecord;
import com.example.zhou.entity.enums.CheckType;
import com.example.zhou.param.FileUploadResultBO;
import com.example.zhou.param.ZoneUploadResultBO;
import com.example.zhou.service.IFileZoneRecordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
import java.util.Date;

/**
 * @author ZhouQuan
 * @desciption 文件上传操作录控制类
 * @date 2024/5/4 17:09
 */
@Validated
@Slf4j
@RestController
@RequestMapping("/v1/upload/zone")
public class FileUploadController {

    @Resource
    private IFileZoneRecordService iFileZoneRecordService;

    /**
     * 大文件分片上传
     *
     * @param multipartFile    文件二进制数据
     * @param id               文件ID
     * @param name             文件名称
     * @param type             文件类型
     * @param lastModifiedDate 最后修改日期
     * @param fileMd5          文件MD5
     * @param zoneTotalMd5     总分片MD5
     * @param zoneMd5          当前分片MD5
     * @param zoneTotalCount   总分片数量
     * @param zoneNowIndex     当前分片序号
     * @param zoneTotalSize    文件总大小
     * @param zoneStartSize    文件开始位置
     * @param zoneEndSize      文件结束位置
     * @param request          HttpServletRequest 对象
     * @return 返回上传结果
     */
    @PostMapping("/zoneUpload")
    public Result zoneUpload(
            @RequestParam("file") @NotNull(message = "文件不能为空") MultipartFile multipartFile,
            @RequestParam("id") String id,
            @RequestParam("name") String name,
            @RequestParam("type") String type,
            @RequestParam("lastModifiedDate") Date lastModifiedDate,
            @RequestParam("fileMd5") String fileMd5,
            @RequestParam("zoneTotalMd5") String zoneTotalMd5,
            @RequestParam("zoneMd5") String zoneMd5,
            @RequestParam("zoneTotalCount") int zoneTotalCount,
            @RequestParam("zoneNowIndex") int zoneNowIndex,
            @RequestParam("zoneTotalSize") long zoneTotalSize,
            @RequestParam("zoneStartSize") long zoneStartSize,
            @RequestParam("zoneEndSize") long zoneEndSize,
            HttpServletRequest request) {
        long startTime = System.currentTimeMillis();

        // 使用构造函数初始化 ArchiveZoneRecord 对象
        ArchiveZoneRecord archiveZoneRecord = new ArchiveZoneRecord(
                id, name, type, lastModifiedDate, fileMd5, zoneTotalMd5, zoneMd5,
                zoneTotalCount, zoneNowIndex, zoneTotalSize, zoneStartSize, zoneEndSize
        );

        // 调用服务方法进行上传
        ZoneUploadResultBO resultBo = iFileZoneRecordService.zoneUpload(request, archiveZoneRecord, multipartFile);

        long endTime = System.currentTimeMillis();
        log.info("zoneUpload 上传耗时:{} ms", (endTime - startTime));

        return new Result(ResultCode.SUCCESS, resultBo);
    }


    /**
     * 校验文件或者分片的md5值
     *
     * @param ArchiveZoneRecord 文件或者分片信息
     * @param checkType         FILE_EXISTS:校验文件是否存在,ZONE_EXISTS:校验分片是否存在
     * @param request
     * @return
     */
    @PostMapping("/md5Check")
    public Result md5Check(ArchiveZoneRecord ArchiveZoneRecord, CheckType checkType, HttpServletRequest request) {
        long l = System.currentTimeMillis();
        Result result = iFileZoneRecordService.md5Check(ArchiveZoneRecord, checkType, request);
        log.info("md5Check校验耗时:{}", System.currentTimeMillis() - l);
        return result;
    }

    /**
     * 合并文件
     * 前端所有分片上传完成时,发起请求,将所有的文件合并成一个完整的文件
     *
     * @param totalMd5 总文件的MD5值
     * @param request
     * @return
     */
    @PostMapping("/merge")
    public Result mergeZoneFile(@RequestParam("totalMd5") String totalMd5, HttpServletRequest request) {
        long l = System.currentTimeMillis();
        FileUploadResultBO result = iFileZoneRecordService.mergeZoneFile(totalMd5, request);
        log.info("merge合并校验耗时:{}", System.currentTimeMillis() - l);
        return new Result(ResultCode.SUCCESS, result);
    }

}

核心实现方法:FileZoneRecordServiceImpl.java

package com.example.zhou.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.zhou.common.Result;
import com.example.zhou.common.ResultCode;
import com.example.zhou.config.FileUploadConfig;
import com.example.zhou.entity.Archive;
import com.example.zhou.entity.ArchiveZoneRecord;
import com.example.zhou.entity.enums.CheckType;
import com.example.zhou.mapper.ArchiveMapper;
import com.example.zhou.mapper.ArchiveRecordMapper;
import com.example.zhou.param.FileUploadResultBO;
import com.example.zhou.param.ZoneUploadResultBO;
import com.example.zhou.service.IFileRecordService;
import com.example.zhou.service.IFileZoneRecordService;
import com.example.zhou.utils.FileHandleUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class FileZoneRecordServiceImpl extends ServiceImpl<ArchiveRecordMapper, ArchiveZoneRecord> implements IFileZoneRecordService {

    @Resource
    private ArchiveMapper archiveMapper;

    @Resource
    private FileUploadConfig fileUploadConfig;

    @Resource
    private IFileRecordService fileRecordService;

    @Resource
    private ArchiveRecordMapper archiveRecordMapper;

    @Override
    public ZoneUploadResultBO zoneUpload(HttpServletRequest request, ArchiveZoneRecord archiveZoneRecord,
                                         MultipartFile multipartFile) {
        if (multipartFile.isEmpty()) {
            // 如果文件为空,返回错误信息
            throw new RuntimeException("请选择文件");
        }

        try {
            // 根据UUID生成同步锁,避免多线程竞争,确保线程安全

            // 根据MD5和zoneTotalMd5查询分片记录
            ArchiveZoneRecord zoneRecord =
                    archiveRecordMapper.selectOne(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                            .eq(ArchiveZoneRecord::getZoneMd5, archiveZoneRecord.getZoneMd5())
                            .eq(ArchiveZoneRecord::getZoneTotalMd5, archiveZoneRecord.getZoneTotalMd5())
                            .last("limit 1"));

            // 如果分片记录存在,返回已存在的分片记录信息
            if (zoneRecord != null) {
                ZoneUploadResultBO resultBo = new ZoneUploadResultBO(zoneRecord, true,
                        zoneRecord.getZoneNowIndex());
                return resultBo;
            }

            Archive archive = null;
            // 根据MD5和上传类型查询文件记录
            archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archiveZoneRecord.getZoneTotalMd5())
                    .last("limit 1"));
            // (文件秒传)如果文件记录已存在且已经上传完毕,则返回文件已上传的错误信息
            if (archive != null && archive.isZoneFlag() && archive.isMergeFlag()) {
                throw new RuntimeException("文件已经上传");
            }

            // 获取文件md5
            String filemd5 = archiveZoneRecord.getZoneMd5();
            // 如果分片记录的md5为空,则生成md5
            if (StringUtils.isBlank(filemd5)) {
                filemd5 = DigestUtils.md5DigestAsHex(multipartFile.getInputStream());
                archiveZoneRecord.setZoneMd5(filemd5);
            }

            // 获取文件后缀
            String fileSuffix = "." + FilenameUtils.getExtension(multipartFile.getOriginalFilename());

            // 获取保存路径
            String saveFilePath = "";
            String fileRecordId = "";

            // 如果数据库中不存在对应的文件记录,则创建新记录
            if (archive == null) {
                // 保存分片的路径
                saveFilePath = Paths.get(fileUploadConfig.getUploadFolder(), "chunks",
                        archiveZoneRecord.getZoneTotalMd5()).toString();
                // 保存文件记录
                fileRecordId = saveFileRecord(request, archiveZoneRecord, multipartFile.getOriginalFilename(),
                        saveFilePath);
            } else {
                // 如果文件记录已存在,则获取文件记录id
                fileRecordId = archive.getSid();
                saveFilePath = archive.getPath();
            }

            // 生成临时文件文件名
            String serverFileName = filemd5 + fileSuffix + ".chunks";
            // 上传文件
            FileHandleUtil.upload(multipartFile.getInputStream(), saveFilePath, serverFileName);
            // 保存分片记录
            saveFileZoneRecord(archiveZoneRecord, filemd5, fileRecordId, serverFileName, saveFilePath,
                    fileSuffix);

            // 返回结果信息
            ZoneUploadResultBO resultBo = new ZoneUploadResultBO(archiveZoneRecord, false,
                    archiveZoneRecord.getZoneNowIndex());
            return resultBo;
        } catch (Exception e) {
            e.printStackTrace();
            log.error("文件上传错误,错误消息:" + e.getMessage());
            throw new RuntimeException("文件上传错误,错误消息:" + e.getMessage());
        }
    }

    /**
     * 保存分片记录
     *
     * @param archiveZoneRecord
     * @param fileMd5
     * @param fileRecordId
     * @param serverFileName
     * @param localPath
     * @param fileSuffix
     */
    private void saveFileZoneRecord(ArchiveZoneRecord archiveZoneRecord, String fileMd5, String fileRecordId,
                                    String serverFileName, String localPath, String fileSuffix) {
        archiveZoneRecord.setSid(UUID.randomUUID() + "");
        archiveZoneRecord.setZoneMd5(fileMd5);
        archiveZoneRecord.setArchiveSid(fileRecordId);
        archiveZoneRecord.setName(serverFileName);
        archiveZoneRecord.setZonePath(localPath);
        archiveZoneRecord.setZoneCheckDate(new Date());
        archiveZoneRecord.setZoneSuffix(fileSuffix);
        super.saveOrUpdate(archiveZoneRecord);
    }

    private String saveFileRecord(HttpServletRequest request, ArchiveZoneRecord ArchiveZoneRecord,
                                  String originalFilename, String localPath) {
        Archive archive = new Archive();
        archive.setSize(ArchiveZoneRecord.getZoneTotalSize());
        archive.setFileType(FilenameUtils.getExtension(originalFilename));
        archive.setMd5Value(ArchiveZoneRecord.getZoneTotalMd5());
        archive.setOriginalName(originalFilename);
        archive.setPath(localPath);
        archive.setZoneFlag(true);
        archive.setMergeFlag(false);
        archive.setZoneTotal(ArchiveZoneRecord.getZoneTotalCount());
        archive.setZoneDate(LocalDateTime.now());
        fileRecordService.saveOrUpdate(archive);
        return archive.getSid();
    }

    @Override
    public Result md5Check(ArchiveZoneRecord archiveZoneRecord, CheckType checkType, HttpServletRequest request) {
        if (checkType == CheckType.FILE_EXISTS) {
            Archive archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archiveZoneRecord.getZoneTotalMd5())
                    .last("limit 1"));
            return archive != null && archive.isMergeFlag() ?
                    new Result(ResultCode.FILEUPLOADED, archive) :
                    new Result(ResultCode.SERVER_ERROR, "请选择文件上传");
        } else {
            ArchiveZoneRecord ArchiveZoneRecordDB =
                    archiveRecordMapper.selectOne(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                            .eq(ArchiveZoneRecord::getZoneMd5, archiveZoneRecord.getZoneMd5())
                            .eq(ArchiveZoneRecord::getZoneTotalMd5, archiveZoneRecord.getZoneTotalMd5())
                            .last("limit 1"));
            return ArchiveZoneRecordDB != null ?
                    new Result(ResultCode.SUCCESS, ArchiveZoneRecordDB) :
                    new Result(ResultCode.SERVER_ERROR, "分片文件不存在,继续上传");
        }
    }


    /**
     * 合并分片文件并保存到服务器
     *
     * @param totalMd5 分片文件的总MD5值
     * @param request  HttpServletRequest对象
     * @return 返回合并结果
     */
    @Override
    public FileUploadResultBO mergeZoneFile(String totalMd5, HttpServletRequest request) {
        FileUploadResultBO resultBO = new FileUploadResultBO();
        if (totalMd5 == null || totalMd5.trim().length() == 0) {
            throw new RuntimeException("总MD5值不能为空");
        }

        // 查询总MD5值对应的文件信息
        Archive archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                .eq(Archive::getMd5Value, totalMd5)
                .last("limit 1"));
        if (archive == null) {
            throw new RuntimeException("文件MD5:" + totalMd5 + "对应的文件不存在");
        }


        if (archive.isZoneFlag() && archive.isMergeFlag()) {
            // 如果文件已上传并合并完成,则返回文件信息
            resultBO.setFileId(archive.getSid());
            resultBO.setFileInfo(archive);
            Path netPath = Paths.get(fileUploadConfig.getStaticAccessPath(), archive.getFileType(),
                    archive.getPath());
            resultBO.setNetworkPath(netPath.toString());
            return resultBO;
        }

        String fileType = archive.getFileType();

        // 查询分片记录
        List<ArchiveZoneRecord> archiveZoneRecords = super.list(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                .eq(ArchiveZoneRecord::getZoneTotalMd5, totalMd5)
                .orderByAsc(ArchiveZoneRecord::getZoneNowIndex)
        );

        if (CollectionUtils.isEmpty(archiveZoneRecords)) {
            throw new RuntimeException("文件MD5:" + totalMd5 + "对应的分片记录不存在");
        }

        // 获取当前日期和时间用于生成文件路径
        String pathDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MMdd/HH"));
        // 获取文件上传路径(不包含文件名) 示例:D:/upload/file/2023/03/08/
        String localPath = Paths.get(fileUploadConfig.getUploadFolder(), fileType, pathDate).toString();
        // 生成唯一文件名
        String saveFileName = UUID.randomUUID() + "." + archive.getFileType();

        // 设置文件信息的路径和全路径
        archive.setFullPath(localPath + saveFileName);
        archive.setPath(Paths.get(pathDate, saveFileName).toString());
        archive.setFileName(saveFileName);

        // 合并分片文件并写入文件
        mergeAndWriteFile(localPath, saveFileName, archiveZoneRecords, pathDate, archive);

        // 保存或更新文件信息
        fileRecordService.saveOrUpdate(archive);

        // 获取网络访问路径
        Path netPath = Paths.get(fileUploadConfig.getUploadUrl(), fileUploadConfig.getStaticAccessPath(),
                fileType, pathDate, saveFileName);

        resultBO.setNetworkPath(netPath.toString());
        resultBO.setFileInfo(archive);
        resultBO.setFileId(archive.getSid());
        return resultBO;
    }

    /**
     * 合并分片文件并写入文件
     *
     * @param localPath          存储文件的本地路径
     * @param saveFileName       保存的文件名
     * @param archiveZoneRecords 分片文件的记录列表
     * @param pathDate           文件路径日期部分
     * @param archive            文件档案对象
     */
    private void mergeAndWriteFile(String localPath, String saveFileName, List<ArchiveZoneRecord> archiveZoneRecords,
                                   String pathDate, Archive archive) {
        String allPath = Paths.get(localPath, saveFileName).toString();
        File targetFile = new File(allPath);

        FileOutputStream fileOutputStream = null;
        try {
            if (!targetFile.exists()) {
                // 创建目录如果不存在
                FileHandleUtil.createDirIfNotExists(localPath);

                // 创建目标临时文件,如果不存在则创建
                targetFile.getParentFile().mkdirs();
                targetFile.createNewFile();
            }

            fileOutputStream = new FileOutputStream(targetFile, true); // 使用追加模式

            // 合并分片文件
            for (ArchiveZoneRecord archiveZoneRecord : archiveZoneRecords) {
                File partFile = new File(archiveZoneRecord.getZonePath(), archiveZoneRecord.getName());
                try (FileInputStream fis = new FileInputStream(partFile)) {
                    byte[] buffer = new byte[1024];
                    int len;
                    while ((len = fis.read(buffer)) != -1) {
                        fileOutputStream.write(buffer, 0, len);
                    }
                }
            }

            // 更新文件信息
            archive.setZoneMergeDate(LocalDateTime.now());
            archive.setMergeFlag(true);
            fileRecordService.saveOrUpdate(archive);

            // 删除由于并发导致文件archive多条重复记录,todo 这里在上传方法中使用乐观锁锁来避免
            fileRecordService.remove(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archive.getMd5Value())
                    .isNotNull(Archive::isMergeFlag)
            );

        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("文件合并失败原因:" + e.getMessage());
        } finally {
            if (fileOutputStream != null) {
                try {
                    fileOutputStream.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

4. 项目运行测试

4.1 测试效果

分片上传-md5

4.2 数据库记录

如下图所示:文件表中存储已经上传到服务器中当前文件的上传信息,文件分片表则记录了当前文件分片所有的分片信息

image-20240614085402463

4.3 上传目录文件

如下图所示:上传目录中存在chunks(分片文件夹)和mp4(合并后的文件)

image-20240614090157192

4.4 网络访问上传的文件

image-20240614090716951

访问效果如下:

image-20240614090820595

5. 项目源码

gitee项目地址:

# 后端地址
git clone https://gitee.com/zhouquanstudy/springboot-file-chunk-md5.git
# 前端地址
git clone https://gitee.com/zhouquanstudy/file-chunk-upload-md5.git

项目压缩包

image-20240614094512339

https://zhouquanquan.lanzouh.com/b00g2d7sdg
密码:bpyg

6.参考链接

  1. 官方地址 https://github.com/fex-team/webuploader
  2. 基于SpringBoot和WebUploader实现大文件分块上传.断点续传.秒传-阿里云开发者社区 (aliyun.com)
  3. 在Vue项目中使用WebUploader实现文件上传_vue webuploader-CSDN博客
  4. vue中大文件上传webuploader前端用法_vue webuploader 大文件上传-CSDN博客
  • 8
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

徐州蔡徐坤

又要到饭了兄弟们

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值