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

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博客

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/714257.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

索引失效有效的11种情况

1全职匹配我最爱 是指 where 条件里 都是 &#xff0c;不是范围&#xff08;比如&#xff1e;,&#xff1c;&#xff09;&#xff0c;不是 不等于&#xff0c;不是 is not null&#xff0c;然后 这几个字段 建立了联合索引 &#xff0c;而且符合最左原则。 那么就要比 只建…

[C++] vector list 等容器的迭代器失效问题

标题&#xff1a;[C] 容器的迭代器失效问题 水墨不写bug 正文开始&#xff1a; 什么是迭代器&#xff1f; 迭代器是STL提供的六大组件之一&#xff0c;它允许我们访问容器&#xff08;如vector、list、set等&#xff09;中的元素&#xff0c;同时提供一个遍历容器的方法。然而…

【Perl】与【Excel】

引言 perl脚本语言对于文本的处理、转换很强大。对于一些信息量庞大的文本文件&#xff0c;看起来不直观&#xff0c;可以将信息提取至excel表格中&#xff0c;增加数据分析的可视化。perl语言的cpan提供了大量模块。对于excel文件的操作主要用到模块&#xff1a; Spreadshee…

Unity的三种Update方法

1、FixedUpdate 物理作用——处理物理引擎相关的计算和刚体的移动 (1) 调用时机&#xff1a;在固定的时间间隔内&#xff0c;而不是每一帧被调用 (2) 作用&#xff1a;用于处理物理引擎的计算&#xff0c;例如刚体的移动和碰撞检测 (3) 特点&#xff1a;能更准确地处理物理…

【算法】某赛车游戏中的组合计数问题及其扩展。推导思路:层层合并

文章目录 引言所有人都能完成可能有人未完成扩展问题参考资料 引言 在某款人称赛车界原神的赛车游戏中有组队竞速赛。共有n个人&#xff0c;n为偶数&#xff0c;分为人数相等的红队和蓝队进行比赛。结果按排名得分的数组为pts&#xff0c;单调递减且均为正整数。比如pts [10,…

算法day28

第一题 295. 数据流的中位数 本题我们是求解给定数组的中位数。且由于需要随时给数组添加元素&#xff0c;所以我们要求解该动态数组的中位数&#xff0c;所以本题最关键的就是维护数组在添加元素之后保持有序的排序&#xff0c;这样就能很快的求解中位数&#xff1b; 解法&am…

C++11完美转发(引用折叠、万能引用)

完美转发是指在函数模板中&#xff0c;完全依照模板的参数的类型&#xff0c;将参数传递给函数模板中调用的另外一个函数。 函数模板在向其他函数传递自身形参时&#xff0c;如果相应实参是左值&#xff0c;它就应该被转发为左值&#xff1b;如果相 应实参是右值&#xff0c;它…

web安全渗透测试十大常规项(一):web渗透测试之PHP反序列化

渗透测试之XSS跨站脚本攻击 1. PHP反序列化1.1 什么是反序列化操作? - 类型转换1.2 常见PHP魔术方法?- 对象逻辑(见图)1.2.1 construct和destruct1.2.2 construct和sleep1.2.2 construct和wakeup1.2.2 INVOKE1.2.2 toString1.2.2 CALL1.2.2 get()1.2.2 set()1.2.2 isset()1…

查看npm版本异常,更新nvm版本解决问题

首先说说遇见的问题&#xff0c;基本上把nvm&#xff0c;npm的坑都排了一遍 nvm版本导致npm install报错 Unexpected token ‘.‘install和查看node版本都正确&#xff0c;结果查看npm版本时候报错 首先就是降低node版本… 可以说基本没用&#xff0c;如果要降低版本的话&…

linxu-Ubuntu系统上卸载Kubernetes-k8s

如果您想从Ubuntu系统上卸载Kubernetes集群&#xff0c;您需要执行以下步骤&#xff1a; 1.关闭Kubernetes集群&#xff1a; 如果您的集群还在运行&#xff0c;首先您需要使用kubeadm命令来安全地关闭它&#xff1a; sudo kubeadm reset在执行该命令后&#xff0c;系统会提示…

【JavaEE进阶】——利用框架完成功能全面的图书管理系统

目录 &#x1f6a9;项目所需要的技术栈 &#x1f6a9;项目准备工作 &#x1f388;环境准备 &#x1f388;数据库准备 &#x1f6a9;前后端交互分析 &#x1f388;登录 &#x1f4dd;前后端交互 &#x1f4dd;实现服务器代码 &#x1f4dd;测试前后端代码是否正确 &am…

01 - matlab m_map地学绘图工具基础函数理解(一)

01 - matlab m_map地学绘图工具基础函数理解&#xff08;一&#xff09; 0. 引言1. m_demo2. 小结 0. 引言 上篇介绍了m_map的配置过程&#xff0c;本篇开始介绍下m_map中涉及到的所有可调用函数。如果配置的没有问题&#xff0c;执行">>help m_map"可以看到类…

【C++】C++入门的杂碎知识点

思维导图大纲&#xff1a; namespac命名空间 什么是namespace命名空间namespace命名空间有什么用 什么是命名空间 namespace命名空间是一种域&#xff0c;它可以将内部的成员隔绝起来。举个例子&#xff0c;我们都知道有全局变量和局部变量&#xff0c;全局变量存在于全局域…

趣味C语言——【猜数字】小游戏

&#x1f970;欢迎关注 轻松拿捏C语言系列&#xff0c;来和 小哇 一起进步&#xff01;✊ &#x1f389;创作不易&#xff0c;请多多支持&#x1f389; &#x1f308;感谢大家的阅读、点赞、收藏和关注&#x1f495; &#x1f339;如有问题&#xff0c;欢迎指正 感谢 目录 代码…

抖音混剪素材哪里找?可以混剪搬运视频素材网站分享

在抖音上制作精彩的视频离不开高质量的素材资源。今天&#xff0c;我将为大家推荐几个优质的网站&#xff0c;帮助你解决素材短缺的问题。这些网站不仅提供丰富的素材&#xff0c;还符合百度SEO优化的规则&#xff0c;让你的视频更容易被发现。 蛙学府素材网 首先要推荐的是蛙…

模拟自动滚动并展开所有评论列表以及回复内容(如:抖音、b站等平台)

由于各大视频平台的回复内容排序不都是按照时间顺序&#xff0c;而且想看最新的评论回复讨论内容还需逐个点击展开&#xff0c;真的很蛋疼&#xff0c;尤其是热评很多的情况&#xff0c;还需要多次点击展开&#xff0c;太麻烦&#xff01; 于是写了一个自动化展开所有评论回复…

诊断解决方案——CANdesc和MICROSAR

文章目录 一、CANdesc二、MICROSAR一、CANdesc canbeded是Vector汽车电子开发软件Nun Autosar标准的工具链之一。 canbeded是以源代码的形式提供的可重用的组件,包括CAN Driver,交互层(IL),网络管理(NM),传输层(TP),诊断层(CANdesc) , 通信测量和标定协议(CCP,XCP) 和 通信控…

Es 索引查询排序分析

文章目录 概要一、Es数据存储1.1、_source1.2、stored fields 二、Doc values2.1、FieldCache2.2、DocValues 三、Fielddata四、Index sorting五、小结六、参考 概要 倒排索引 优势在于快速的查找到包含特定关键词的所有文档&#xff0c;但是排序&#xff0c;过滤、聚合等操作…

并发容器(二):Concurrent类下的ConcurrentHashMap源码级解析

并发容器-ConcurrentHashMap 前言数据结构JDK1.7版本HashEntrySegment 初始化 重要方法Put方法扩容rehash方法 前言 之前我们在集合篇里聊完了HashMap和HashTable&#xff0c;我们又学习了并发编程的基本内容&#xff0c;现在我们来聊一聊Concurrent类下的重要容器&#xff0c…

tsp可视化python

随机生成点的坐标并依据点集生成距离矩阵&#xff0c;通过点的坐标实现可视化 c代码看我的这篇文章tsp动态规划递归解法c from typing import List, Tuple import matplotlib.pyplot as plt from random import randintN: int 4 MAX: int 0x7f7f7f7fdistances: List[List[in…