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', //检测文件是否存在urlcheckChunkUrl: '/v1/upload/zone/md5Check', //检测分片urlmergeChunksUrl: '/v1/upload/zone/merge', //合并文件请求地址 提交测试headers: {}}}},fileListData: {type: Array,default: function () {return []}}},data() {return {fileList: [], // 存储等待上传文件列表的数组percentage: 0, // 上传进度,初始化为0uploader: {}, // WebUploader实例对象uploadStatus: 'el-icon-upload', // 上传状态图标,默认为上传图标uploadStartTime: null, // 文件上传开始时间uploadedFiles: [] // 存储上传成功文件信息的数组}},mounted() {this.register()this.initUploader()this.initEvents()// 监视 fileListData 变化,并将其赋值给 fileListthis.$watch('fileListData', (newVal) => {this.fileList = [...newVal];});},methods: {initUploader() {var fileType = this.options.fileTypethis.uploader = webUploader.create({// 不压缩imageresize: 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 = 1that.$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 = 1that.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.sizeconst speed = this.formatFileSize(uploadedSize / elapsedTime) + '/s'// 更新文件信息targetFile.percentage = parseFloat((percentage * 100).toFixed(2))targetFile.speed = speedtargetFile.startTime = targetFile.startTime || currentTime}})this.uploader.on('uploadSuccess', (file, response) => {this.uploadedFiles = []if (response.code === 10000) {response.data.fileName = response.data.originalNameresponse.data.percentage = this.fileList[0].percentageresponse.data.fileSize = this.fileList[0].fileSizeresponse.data.speed = this.fileList[0].speedthis.uploadedFiles.push(response.data)// this.$message.success('上传完成')} else {this.$message.error('上传失败: ' + response.message)}})/**上传之前**/uploader.on('uploadBeforeSend', function (block, data, headers) {data.fileMd5 = block.file.fileMd5data.contentType = block.file.typedata.chunks = block.file.chunksdata.zoneTotalMd5 = block.file.fileMd5data.zoneMd5 = block.zoneMd5data.zoneTotalCount = block.chunksdata.zoneNowIndex = block.chunkdata.zoneTotalSize = block.totaldata.zoneStartSize = block.startdata.zoneEndSize = block.endheaders.Authorization = that.options.headers.Authorization})uploader.on('uploadFinished', function () {that.percentage = 1that.uploadStaus = 'el-icon-upload'that.$message.success({showClose: true,message: '文件上传完毕'})})},setTableBtn(fileId, showmsg, sid) {var fileList = this.fileListfor (var i = 0; i < fileList.length; i++) {if (fileList[i].fileId == fileId) {this.fileList[i].progress = showmsgthis.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 = thisthat.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 = 0if (size / 1024 > 1024) {var len = size / 1024 / 1024fileSize = len.toFixed(2) + 'MB'} else if (size / 1024 / 1024 > 1024) {len = size / 1024 / 1024fileSize = len.toFixed(2) + 'GB'} else {len = size / 1024fileSize = 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 {@Resourceprivate 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 {@Resourceprivate ArchiveMapper archiveMapper;@Resourceprivate FileUploadConfig fileUploadConfig;@Resourceprivate IFileRecordService fileRecordService;@Resourceprivate ArchiveRecordMapper archiveRecordMapper;@Overridepublic 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("文件已经上传");}// 获取文件md5String filemd5 = archiveZoneRecord.getZoneMd5();// 如果分片记录的md5为空,则生成md5if (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 {// 如果文件记录已存在,则获取文件记录idfileRecordId = 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();}@Overridepublic 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 返回合并结果*/@Overridepublic 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博客

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

MySQL查询数据库中所有表名表结构及注释以及生成数据库文档

MySQL查询数据库中所有表名表结构及注释 生成数据库文档在后面&#xff01;&#xff01;&#xff01; select t.TABLE_COMMENT -- 数据表注释 , c.TABLE_NAME -- 表名称 , c.COLUMN_COMMENT -- 数据项 , c.COLUMN_NAME -- 英文名称 , -- 字段描述 , upper(c.DATA_TYPE) as …

视频字幕提取工具怎么使用?不妨看看这些教程

在探索学习设备使用的过程中&#xff0c;视频教程扮演着极其重要的角色。 但是&#xff0c;我们可能会遇到一些挑战&#xff0c;比如长视频教程的观看效率不高&#xff0c;信息量大难以快速定位到关键点&#xff0c;或者有些人更喜欢阅读文字而非观看视频来学习。 为解决这一…

【JavaEE精炼宝库】多线程(5)单例模式 | 指令重排序 | 阻塞队列

目录 一、单例模式&#xff1a; 1.1 饿汉模式&#xff1a; 1.2 懒汉模式&#xff1a; 1.2.1 线程安全的懒汉模式&#xff1a; 1.2.2 线程安全的懒汉模式的优化&#xff1a; 二、指令重排序 三、阻塞队列 3.1 阻塞队列的概念&#xff1a; 3.2 生产者消费者模型&#xf…

Docker部署常见应用之大数据基础框架Hadoop

文章目录 Hadoop简介主要特点核心组件生态系统 Docker Compose 部署集群参考文章 Hadoop简介 Hadoop是一个开源框架&#xff0c;由Apache软件基金会开发&#xff0c;用于在普通硬件构建的集群中存储和处理大量数据。它最初由Doug Cutting和Mike Cafarella创建&#xff0c;并受…

H5小程序视频编辑解决方案,广泛适用,灵活部署

如何在微信小程序、网页、HTML5等WEB场景中实现轻量化视频制作&#xff0c;满足多样化的运营需求&#xff0c;一直是企业面临的挑战。美摄科技凭借其在视频编辑领域的深厚积累和创新技术&#xff0c;为企业量身打造了一套H5/小程序视频编辑解决方案&#xff0c;助力企业轻松应对…

C++笔记:模板

模板 为什么要学习模板编程 在学习模板之前&#xff0c;一定要有算法及数据结构的基础&#xff0c;以及重载&#xff0c;封装&#xff0c;多态&#xff0c;继承的基础知识&#xff0c;不然会出现看不懂&#xff0c;或者学会了没办法使用。 为什么C会有模板&#xff0c;来看下面…

JVM性能优化案例:减少对象频繁创建

JVM性能优化案例&#xff1a;减少对象频繁创建 案例背景 某金融应用系统在处理大量并发交易时&#xff0c;响应时间过长&#xff0c;并且有时出现内存溢出&#xff08;OutOfMemoryError&#xff09;的问题。经过分析&#xff0c;发现问题主要出在频繁的对象创建和较差的内存管…

git的ssh安装,windows通过rsa生成密钥认证问题解决

1 windows下载 官网下载可能出现下载太慢的情况&#xff0c;Git官网下载地址为&#xff1a;官网&#xff0c;推荐官网下载&#xff0c;如无法下载&#xff0c;可移步至CSDN&#xff0c;csdn下载地址&#xff1a;https://download.csdn.net/download/m0_46309087/12428308 2 Gi…

Perl 语言学习进阶

一、如何深入 要深入学习Perl语言的库和框架&#xff0c;可以按照以下步骤进行&#xff1a; 了解Perl的核心模块&#xff1a;Perl有许多核心模块&#xff0c;它们提供了许多常用的功能。了解这些模块的功能和用法是深入学习Perl的第一步。一些常用的核心模块包括&#xff1a;S…

如何在 Windows 10/11 上编辑 PDF [4 种简单方法]

PDF 在大多数设备上都易于查看&#xff0c;但由于其设计用于查看&#xff0c;因此编辑起来可能比较棘手。编辑 PDF 可能比编辑 Microsoft Office 文档更具挑战性。 不用担心&#xff0c;我们已经为你做好了准备。无论你是想添加、删除还是插入文本或图片&#xff0c;你都可以使…

Coze+Discord:打造你的免费AI助手(教您如何免费使用GPT-4o/Gemini等最新最强的大模型/Discord如何正确连接Coze)

文章目录 📖 介绍 📖🏡 演示环境 🏡📒 文章内容 📒📝 准备Discord📝 准备Coze🔌 连接💡 测试效果⚓️ 相关链接 ⚓️📖 介绍 📖 你是否想免费使用GPT-4o/Gemini等最新最强的大模型,但又不想花费高昂的费用?本文将教你如何通过Coze搭建Bot,并将其转发…

【AI绘画】Stable Diffusion 3开源

Open Release of Stable Diffusion 3 Medium 主要内容 Stable Diffusion 3是Stability AI目前为止最先进的文本转图像开放源代码算法。 这款模型的小巧设计使其完美适合用于消费级PC和笔记本电脑&#xff0c;以及企业级图形处理单元上运行。它已经满足了标准化的文字转图像模…

AI办公自动化:批量合并多个Excel表格的数据并汇总

工作任务&#xff1a; 有多个表格 把里面的月流量数据都合并到一张表中&#xff1a; 在chatgpt中输入提示词&#xff1a; 你是一个Python编程专家&#xff0c;要完成一个Python脚本编写任务&#xff0c;具体步骤如下&#xff1a; 打开文件夹&#xff1a;F:\AI自媒体内容\AI行…

计算机视觉全系列实战教程:(九)图像滤波操作

1.图像滤波的概述 (1)Why (为什么要进行图像滤波) 去噪&#xff1a;去除图像在获取、传输等过程中的各种噪音干扰提取特征&#xff1a;使用特定的图像滤波器提取图像特定特征 (2)What (什么是图像滤波) 使用滤波核对图像进行卷积运算或非线性运算&#xff0c;以达到去噪或提…

11.2 Go 常用包介绍

&#x1f49d;&#x1f49d;&#x1f49d;欢迎莅临我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:「stormsha的主页」…

使用‘消除’技术绕过LLM的安全机制,不用训练就可以创建自己的nsfw模型

开源的大模型在理解和遵循指令方面都表现十分出色。但是这些模型都有审查的机制&#xff0c;在获得被认为是有害的输入的时候会拒绝执行指令&#xff0c;例如会返回“As an AI assistant, I cannot help you.”。这个安全功能对于防止误用至关重要&#xff0c;但它限制了模型的…

化学品危险性分类鉴定报告 危化品危险性分类

一、化学品危险性分类报告&#xff1a; 按照国务院令 第591号 《危险化学品安全管理条例》、原十部委公告 2015年 第5号 《危险化学品目录&#xff08;2015版&#xff09;》、原安监总局令 第60号《化学品物理危险性鉴定与分类管理办法》和原安监总局令 第53号《危险化学品登记…

IBM Spectrum LSF Process Manager 在共享分布式计算环境中运行和管理业务关键工作流程

亮点 ● 快速创建复杂的分布式工作流 ● 开发可重复的最佳实践 ● 自信地运行关键工作流程 ● 提高流程可靠性 IBM Spectrum LSF Process Manager 使您能够设计和自动化计算或分析流程&#xff0c; 捕获和保护可重复的最佳实践。 使用直观的图形界面&#xff0c;您可以轻松记录…

【漏洞复现】飞企互联-FE企业运营管理平台 treeXml.jsp SQL注入漏洞

0x01 产品简介 飞企互联-FE企业运营管理平台是一个基于云计算、智能化、大数据、物联网、移动互联网等技术支撑的云工作台。这个平台可以连接人、链接端、联通内外&#xff0c;支持企业B2B、C2B与020等核心需求&#xff0c;为不同行业客户的互联网转型提供支持。其特色在于提供…

【十大排序算法】基数排序

数字犹如无数繁星&#xff0c;基数排序如晨曦的指引&#xff0c;将混沌序列织就成和谐的序曲。 文章目录 一、基数排序二、发展历史三、处理流程四、算法实现五、算法特性六、小结推荐阅读 一、基数排序 基数排序是一种非比较性的排序算法&#xff0c;它根据元素的位数来对元…