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