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 中的实现方法,包括分片上传、断点续传和秒传技术。效果图如下:
1. 基本概念
1.1 分片上传
分片上传的核心思想是将一个大文件分成若干份大小相等的多个小块数据块(称为 Part)。所有小块文件上传成功后,再将其合并成完整的原始文件。
分片上传的优点:
- 断点续传:在网络中断或其他错误导致上传失败时,只需重新上传失败的部分,而不必从头开始上传整个文件,从而提高上传的可靠性和效率。
- 降低网络压力:分片上传可以控制每个片段的大小,避免一次性传输大量数据导致的网络拥堵,提高网络资源的利用率。
- 并行上传:多个分片可以同时上传,加快整体上传速度。
- 灵活处理:服务器可以更灵活地处理和存储文件分片,减少内存和带宽的占用。
1.2 断点续传
断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为划分为几个部分,每个部分采用一个线程进行上传或下载。如果遇到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而无需从头开始。
断点续传的实现过程:
- 前端将文件按百分比进行计算,每次上传文件的百分之一(文件分片),给文件分片编号。
- 后端将前端每次上传的文件放入缓存目录。
- 前端全部文件上传完毕后,发送合并请求。
- 后端使用
RandomAccessFile
进行多线程读取所有分片文件,一个线程一个分片。 - 后端每个线程按序号将分片文件写入目标文件中。
- 上传过程中发生断网或手动暂停,下次上传时发送续传请求,后端删除最后一个分片。
- 前端重新发送上次的文件分片。
1.3 秒传
文件上传中的“秒传”是一种优化文件上传过程的技术。其主要原理是通过文件的特征值(通常是文件的哈希值,如 MD5、SHA-1 或 SHA-256 等)来判断文件是否已经存在于服务器上,从而避免重复上传相同的文件。
秒传的具体流程:
- 计算文件哈希值:客户端在开始上传文件之前,计算文件的哈希值。
- 发送哈希值:客户端将计算得到的哈希值发送给服务器。
- 服务器校验:服务器根据收到的哈希值查询数据库或文件存储系统,判断是否已存在相同哈希值的文件。
- 如果文件已存在:服务器直接返回文件已存在的信息,客户端即可认为上传完成,不需实际上传文件数据。
- 如果文件不存在:服务器通知客户端继续上传文件数据。
- 上传文件数据:如果服务器通知文件不存在,客户端实际上传文件数据,服务器接收后存储并更新相应哈希值记录。
秒传的优点:
- 节省带宽:避免重复上传相同的文件,特别是在大文件上传场景中效果显著。
- 加快上传速度:用户体验更好,对于已存在的文件可以实现“秒传”。
- 减轻服务器负担:减少不必要的数据传输和存储压力。
秒传技术广泛应用于网盘、云存储、文件共享平台等场景中。
1.4 分片上传的实现
在 SpringBoot 中,可以通过以下步骤实现分片上传:
2.1 前端实现
前端使用 WebUploader 等库实现分片上传。具体步骤如下:
- 使用 WebUploader 初始化上传组件,设置分片大小及其他参数。
- 在文件分片上传前,计算每个分片的哈希值并发送到服务器。
- 服务器验证分片的哈希值,返回是否需要上传该分片。
- 前端根据服务器返回结果,决定是否上传分片。
2.2 后端实现
后端可以使用 SpringBoot 提供的文件上传接口来处理分片上传请求。具体步骤如下:
- 接收并验证前端发送的分片文件及其哈希值。
- 将分片文件保存到临时目录。
- 保存分片文件信息(如序号、哈希值等)到数据库。
- 在接收到所有分片后,合并分片文件为完整文件。
2. 分片上传前端实现
技术栈或技术点:vue、webuploader、elmentui
2.1 什么是WebUploader?
WebUploader 是由百度公司开发的一个现代文件上传组件,主要基于 HTML5,同时辅以 Flash 技术。它支持大文件的分片上传,提高了上传效率,并且兼容主流浏览器。
官网地址: [Web Uploader - Web Uploader (fex-team.github.io)](http://fex.baidu.com/webuploader/)
功能特点
- 分片、并发上传: WebUploader 支持将大文件分割成小片段并行上传,极大地提高了上传效率。
- 预览、压缩: 支持常用图片格式(如 jpg、jpeg、gif、bmp、png)的预览和压缩,节省了网络传输数据量。
- 多途径添加文件: 支持文件多选、类型过滤、拖拽(文件和文件夹)以及图片粘贴功能。
- HTML5 & FLASH: 兼容所有主流浏览器,接口一致,不需要担心内部实现细节。
- MD5 秒传: 通过 MD5 值验证,避免重复上传相同文件。
- 易扩展、可拆分: 采用模块化设计,各功能独立成小组件,可自由组合搭配。
接口说明
WebUploader 提供了丰富的接口和钩子函数,以下是几个关键的接口:
- before-send-file: 在文件发送之前执行。
- before-file: 在文件分片后、上传之前执行。
- after-send-file: 在所有文件分片上传完毕且无错误时执行。
WebUploader 的所有代码都在一个闭包中,对外只暴露了一个变量 WebUploader
,避免与其他框架冲突。所有内部类和功能都通过 WebUploader
命名空间进行访问。
事件API
Uploader
实例拥有类似 Backbone 的事件 API,可以通过 on
、off
、once
和 trigger
进行事件绑定和触发。
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
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
运行后页面效果和最终项目代码结构
3 .分片上传后端实现
3.1 项目结构和技术介绍
本项目的后端采用Spring Boot框架,结合MyBatis-Plus以提高数据库操作的效率。数据库使用MySQL,提供高性能和可靠性。这些技术的组合确保了系统的稳定性和高效性,并简化了开发和维护过程
3.2 核心代码
控制类:FileUploadController.java
FileUploadController
类负责处理文件上传相关的操作。其主要功能包括:
- 大文件分片上传:处理前端分片上传的大文件请求,接收并记录文件片段信息。
- MD5校验:校验文件或分片的MD5值,检查文件或分片是否已经存在,以避免重复上传。
- 文件合并:在所有分片上传完成后,将所有分片合并成一个完整的文件。
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 测试效果
4.2 数据库记录
如下图所示:文件表中存储已经上传到服务器中当前文件的上传信息,文件分片表则记录了当前文件分片所有的分片信息
4.3 上传目录文件
如下图所示:上传目录中存在chunks(分片文件夹)和mp4(合并后的文件)
4.4 网络访问上传的文件
访问效果如下:
5. 项目源码
gitee项目地址:
# 后端地址
git clone https://gitee.com/zhouquanstudy/springboot-file-chunk-md5.git
# 前端地址
git clone https://gitee.com/zhouquanstudy/file-chunk-upload-md5.git
项目压缩包
https://zhouquanquan.lanzouh.com/b00g2d7sdg
密码:bpyg
6.参考链接
- 官方地址 https://github.com/fex-team/webuploader
- 基于SpringBoot和WebUploader实现大文件分块上传.断点续传.秒传-阿里云开发者社区 (aliyun.com)
- 在Vue项目中使用WebUploader实现文件上传_vue webuploader-CSDN博客
- vue中大文件上传webuploader前端用法_vue webuploader 大文件上传-CSDN博客
相关文章:

SpringBoot 大文件基于md5实现分片上传、断点续传、秒传
SpringBoot 大文件基于md5实现分片上传、断点续传、秒传 SpringBoot 大文件基于md5实现分片上传、断点续传、秒传前言1. 基本概念1.1 分片上传1.2 断点续传1.3 秒传1.4 分片上传的实现 2. 分片上传前端实现2.1 什么是WebUploader?功能特点接口说明事件APIHook 机制 …...

数据资产治理与数据质量提升:构建完善的数据治理体系,确保数据资产的高质量与准确性
一、引言 随着信息技术的迅猛发展,数据已经成为企业和社会发展的重要资产。然而,数据资产的有效治理与数据质量的提升,是企业实现数字化转型、提升竞争力的关键。本文旨在探讨数据资产治理与数据质量提升的重要性,并提出构建完善…...

SylixOS下UDP组播测试程序
SylixOS下UDP组播测试 测试效果截图如下: udp组播发送测试程序。 /********************************************************************************************************* ** ** 中国软件开源组织 ** ** …...
Web前端快速开发平台:革命性工具,提升开发效率的新篇章
Web前端快速开发平台:革命性工具,提升开发效率的新篇章 在数字化时代的浪潮中,Web前端技术的快速发展与变革正在重塑我们的数字世界。为了应对这种快速变化,Web前端快速开发平台应运而生,为开发者们提供了更加高效、便…...
内窥镜系统设计简介
内窥镜系统设计简介 1. 源由2. 系统组成2.1 光学系统2.2 机械结构2.3 电子系统2.4 软件系统2.5 安全性和合规性2.6 研发与测试2.7 用户培训与支持 3. 研发过程3.1 光学系统Step 1:镜头设计Step 2:光源Step 3:成像传感器 3.2 机械结构Step 1&a…...
使用Spring Boot实现Redis多数据库缓存
Redis多数据库存储实现用户行为缓存 在我的系统中,为了优化用户行为数据的存储与访问效率,我引入了Redis缓存,并将数据分布在不同的Redis数据库中。通过这种方式,可以减少单一数据库的负载,提高系统的整体性能。 主要…...
揭秘newSingleThreadExecutor:深度解析与源码探秘
1. 概述 newSingleThreadExecutor是Java线程池框架中Executors类的一个静态方法,它返回一个线程池实例,该线程池维护一个单一的工作线程来执行任务。这个线程池的特性在于它保证了所有提交的任务会按照它们在队列中的顺序依次执行,而不会并发执行。它适用于需要保证任务顺序…...

使用python绘制三维散点图
使用python绘制三维散点图 三维散点图三维散点图的用途效果代码 三维散点图 三维散点图(3D Scatter Plot)是一种用于展示三维数据的图表。与二维散点图类似,三维散点图通过点在三维空间中的位置来表示数据点的三个特征。每个点在 x、y 和 z …...

Vue51-插件
一、插件的定义 vue里面的插件,类似于游戏的外挂。 vue中插件的本质:一个对象,里面必须包含install方法。 二、插件的使用 2-1、创建一个插件js文件(写在src中plugins.js) 2-2、应用插件:Vue.use(插件) …...
python将一个整数转为字符串列表
如果你想要将一个整数转换为字符串列表,其中每个数字(0-9)都是列表中的一个元素,你可以先将整数转换为字符串,然后遍历这个字符串,将每个字符添加到列表中。这里是一个简单的示例: # 假设你有一…...

PTA 6 - 20 汉诺塔问题(py 递归)
这道题是一道比较典型的递归问题,他跟斐波那契数列的本质是一样的,大家自己动手推理一下,非常好推 参考代码: def hanoi(n,a,b,c):global stepif n 1:print(a,"->",c)step 1else:hanoi(n-1,a,c,b)print(a,"…...

深度学习Day-20:DenseNet算法实战 乳腺癌识别
🍨 本文为:[🔗365天深度学习训练营] 中的学习记录博客 🍖 原作者:[K同学啊 | 接辅导、项目定制] 一、 基础配置 语言环境:Python3.8编译器选择:Pycharm深度学习环境: torch1.12.1c…...

给类设置serialVersionUID
第一步打开idea设置窗口(setting窗口默认快捷键CtrlAltS) 第二步搜索找到Inspections 第三步勾选主窗口中Java->Serializations issues->下的Serializable class without serialVersionUID’项 ,并点击“OK”确认 第四步鼠标选中要加…...
Android之实现两段颜色样式不同的文字拼接进行富文本方式的显示
一、使用SpannableString进行拼接 1、显示例子 前面文字显示红色,后面显示白色,显示在一个TextView中,可以自动换行 发送人姓名: 发送信息内容2、TextView <TextViewandroid:id"id/tv_msg"android:layout_width"wrap_c…...

GenICam标准(五)
系列文章目录 GenICam标准(一) GenICam标准(二) GenICam标准(三) GenICam标准(四) GenICam标准(五) GenICam标准(六) 文章目录 系列文…...

《人生海海》读后感
麦家是写谍战的高手,《暗算》《风声》等等作品被搬上荧屏后,掀起了一阵一阵的收视狂潮。麦家声名远扬我自然是知道的,然而我对谍战似乎总是提不起兴趣,因此从来没有拜读过他的作品。这几天无聊时在网上找找看看,发现了…...

SpringBoot自定义Starter及原理分析
目录 1.前言2.环境3.准备Starter项目4.准备AutoConfigure项目4.1 准备类HelloProperties4.2 准备类HelloService4.3 准备类HelloServiceAutoConfiguration4.4 创建spring.factories文件并引用配置类HelloServiceAutoConfiguration4.5 安装到maven仓库 5.在其他项目中引入自定义…...

YOLOv10网络架构及特点
YOLOv10简介 YOLOv10是清华大学的研究人员在Ultralytics Python包的基础上,引入了一种新的实时目标检测方法,解决了YOLO 以前版本在后处理和模型架构方面的不足。通过消除非最大抑制(NMS)和优化各种模型组件,YOLOv…...

基于单片机的多功能智能小车设计
第一章 绪论 1.1 课题背景和意义 随着计算机、微电子、信息技术的快速发展,智能化技术的发展速度越来越快,智能化与人们生活的联系也越来越紧密,智能化是未来社会发展的必然趋势。智能小车实际上就是一个可以自由移动的智能机器人,比较适合在人们无法工作的地方工作,也可…...

Python时间序列分析库
Sktime Welcome to sktime — sktime documentation 用于ML/AI和时间序列的统一API,用于模型构建、拟合、应用和验证支持各种学习任务,包括预测、时间序列分类、回归、聚类。复合模型构建,包括具有转换、集成、调整和精简功能的管道scikit学习式界面约定的交互式用户体验Pro…...
【杂谈】-递归进化:人工智能的自我改进与监管挑战
递归进化:人工智能的自我改进与监管挑战 文章目录 递归进化:人工智能的自我改进与监管挑战1、自我改进型人工智能的崛起2、人工智能如何挑战人类监管?3、确保人工智能受控的策略4、人类在人工智能发展中的角色5、平衡自主性与控制力6、总结与…...
C++:std::is_convertible
C++标志库中提供is_convertible,可以测试一种类型是否可以转换为另一只类型: template <class From, class To> struct is_convertible; 使用举例: #include <iostream> #include <string>using namespace std;struct A { }; struct B : A { };int main…...
Spring Boot面试题精选汇总
🤟致敬读者 🟩感谢阅读🟦笑口常开🟪生日快乐⬛早点睡觉 📘博主相关 🟧博主信息🟨博客首页🟫专栏推荐🟥活动信息 文章目录 Spring Boot面试题精选汇总⚙️ **一、核心概…...

WordPress插件:AI多语言写作与智能配图、免费AI模型、SEO文章生成
厌倦手动写WordPress文章?AI自动生成,效率提升10倍! 支持多语言、自动配图、定时发布,让内容创作更轻松! AI内容生成 → 不想每天写文章?AI一键生成高质量内容!多语言支持 → 跨境电商必备&am…...

Android 之 kotlin 语言学习笔记三(Kotlin-Java 互操作)
参考官方文档:https://developer.android.google.cn/kotlin/interop?hlzh-cn 一、Java(供 Kotlin 使用) 1、不得使用硬关键字 不要使用 Kotlin 的任何硬关键字作为方法的名称 或字段。允许使用 Kotlin 的软关键字、修饰符关键字和特殊标识…...

Reasoning over Uncertain Text by Generative Large Language Models
https://ojs.aaai.org/index.php/AAAI/article/view/34674/36829https://ojs.aaai.org/index.php/AAAI/article/view/34674/36829 1. 概述 文本中的不确定性在许多语境中传达,从日常对话到特定领域的文档(例如医学文档)(Heritage 2013;Landmark、Gulbrandsen 和 Svenevei…...

2025季度云服务器排行榜
在全球云服务器市场,各厂商的排名和地位并非一成不变,而是由其独特的优势、战略布局和市场适应性共同决定的。以下是根据2025年市场趋势,对主要云服务器厂商在排行榜中占据重要位置的原因和优势进行深度分析: 一、全球“三巨头”…...
蓝桥杯 冶炼金属
原题目链接 🔧 冶炼金属转换率推测题解 📜 原题描述 小蓝有一个神奇的炉子用于将普通金属 O O O 冶炼成为一种特殊金属 X X X。这个炉子有一个属性叫转换率 V V V,是一个正整数,表示每 V V V 个普通金属 O O O 可以冶炼出 …...
IP如何挑?2025年海外专线IP如何购买?
你花了时间和预算买了IP,结果IP质量不佳,项目效率低下不说,还可能带来莫名的网络问题,是不是太闹心了?尤其是在面对海外专线IP时,到底怎么才能买到适合自己的呢?所以,挑IP绝对是个技…...
A2A JS SDK 完整教程:快速入门指南
目录 什么是 A2A JS SDK?A2A JS 安装与设置A2A JS 核心概念创建你的第一个 A2A JS 代理A2A JS 服务端开发A2A JS 客户端使用A2A JS 高级特性A2A JS 最佳实践A2A JS 故障排除 什么是 A2A JS SDK? A2A JS SDK 是一个专为 JavaScript/TypeScript 开发者设计的强大库ÿ…...