大文件批量上传断点续传文件秒传
接上篇文章 java 超大文件分片上传 在其基础上继续实现 断点续传和文件秒传功能
在上篇中,我们可以使用 file. slice 方法对文件进行分片,可以从后台读到当前文件已经上传的大小,就可以知道从哪里开始切片,断点续传的原理就是基于这个的。
前端计算文件的 md5 ,后台数据库查询一遍(前提是把 md5 存储了,计算文件 md5 也是需要消耗时间的)即可知道是否有相同文件,这是实现文件秒传的方法。
可能存在的问题:
- 有两个人同时在上传同一个文件,但前一个人还没有上传完成,此时第二个文件认为是新文件不能秒传
- 此时获取文件原数据时需要将文件信息保存起来,重点是要保存 md5 ,保证一个文件的 md5 保计算一次
- 获取断点文件时,真实的文件上传位置应该是从文件系统中读出来的
根据需求说明,后台应该存在四个接口,获取文件信息(包含是否可以秒传),获取断点文件列表,分片上传接口,文件完整性验证
全部源码位置 : https://gitee.com/sanri/example/tree/master/test-mvc
/**
* 加载断点文件列表
* @return
*/
@GetMapping("/breakPointFiles")
public List<FileInfoPo> breakPointFiles(){
List<FileInfoPo> fileInfoPos = fileMetaDataRepository.breakPointFiles();
return fileInfoPos;
}
/**
* 获取文件元数据,判断文件是否可以秒传
* @param originFileName
* @param fileSize
* @param md5
* @return
* @throws URISyntaxException
*/
@GetMapping("/fileMetaData")
public FileMetaData fileMetaData(String originFileName, Long fileSize, String md5) throws URISyntaxException, MalformedURLException {
FileMetaData similarFile = bigFileStorage.checkSimilarFile(originFileName,fileSize, md5);
if(similarFile != null){
similarFile.setSecUpload(true);
// 如果文件名不一致,则创建链接文件
if(!similarFile.getOriginFileName() .equals(originFileName)) {
bigFileStorage.createSimilarLink(similarFile);
}
return similarFile;
}
//获取文件相关信息
String baseName = FilenameUtils.getBaseName(originFileName);
String extension = FilenameUtils.getExtension(originFileName);
String finalFileName = bigFileStorage.rename(baseName, fileSize);
if(StringUtils.isNotEmpty(extension)){
finalFileName += ("."+extension);
}
URI relativePath = bigFileStorage.relativePath(finalFileName);
//如果没有相似文件,则要创建记录到数据库中,为后面断点续传做准备
FileInfoPo fileInfoPo = new FileInfoPo();
fileInfoPo.setName(originFileName);
fileInfoPo.setType(extension);
fileInfoPo.setUploaded(0);
fileInfoPo.setSize(fileSize);
fileInfoPo.setRelativePath(relativePath.toString());
fileInfoPo.setMd5(md5);
fileMetaDataRepository.insert(fileInfoPo);
URI absoluteURI = bigFileStorage.absolutePath(relativePath);
FileMetaData fileMetaData = new FileMetaData(originFileName, finalFileName, fileSize, relativePath.toString(), absoluteURI.toString());
fileMetaData.setMd5(md5);
fileMetaData.setFileType(extension);
return fileMetaData;
}
/**
* 获取当前文件已经上传的大小,用于断点续传
* @return
*/
@GetMapping("/filePosition")
public long filePosition(String relativePath) throws IOException, URISyntaxException {
return bigFileStorage.filePosition(relativePath);
}
/**
* 上传分段
* @param multipartFile
* @return
*/
@PostMapping("/uploadPart")
public long uploadPart(@RequestParam("file") MultipartFile multipartFile, String relativePath) throws IOException, URISyntaxException {
bigFileStorage.uploadPart(multipartFile,relativePath);
return bigFileStorage.filePosition(relativePath);
}
/**
* 检查文件是否完整
* @param relativePath
* @param fileSize
* @param md5
* @return
*/
@GetMapping("/checkIntegrity")
public void checkIntegrity(String relativePath,Long fileSize,String fileName) throws IOException, URISyntaxException {
long filePosition = bigFileStorage.filePosition(relativePath);
Assert.isTrue(filePosition == fileSize ,"大文件上传失败,文件大小不完整 "+filePosition+" != "+fileSize);
String targetMd5 = bigFileStorage.md5(relativePath);
FileInfoPo fileInfoPo = fileMetaDataRepository.selectByPrimaryKey(fileName);
String md5 = fileInfoPo.getMd5();
Assert.isTrue(targetMd5.equals(md5),"大文件上传失败,文件损坏 "+targetMd5+" != "+md5);
//如果文件上传成功,更新文件上传大小
fileMetaDataRepository.updateFilePosition(fileName,filePosition);
}
重要的处理部分其实还是前端,下面看前端的代码,需要使用到一个计算 md5 值的库 spark-md5.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>大文件批量上传,支持断点续传,文件秒传</title>
<style>
.upload-item{
padding: 15px 10px;
list-style-type: none;
display: flex;
flex-direction: row;
margin-bottom: 10px;
border: 1px dotted lightgray;
width: 1000px;
position: relative;
}
.upload-item:before{
content: \' \';
background-color: lightblue;
width: 0px;
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: -1;
}
.upload-item span{
display: block;
margin-left: 20px;
}
.upload-item>.file-name{
width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-item>.upload-process{
width: 50px;
text-align: left;
}
.upload-item>.upload-status{
width: 100px;
text-align: center;
}
table{
width: 100%;
border-collapse: collapse;
position: fixed;
bottom: 200px;
border: 1px solid whitesmoke;
}
</style>
</head>
<body>
<div class="file-uploads">
<input type="file" multiple id="file" />
<button id="startUpload">开始上传</button>
<ul id="uploadfiles">
</ul>
<table class="" style="" id="table" >
<thead>
<tr>
<td>文件名</td>
<td>文件大小</td>
<td>已上传大小</td>
<td>相对路径</td>
<td>md5</td>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- <script src="jquery-1.8.3.min.js"></script>-->
<script src="jquery1.11.1.min.js"></script>
<script src="spark-md5.min.js"></script>
<script>
const root = \'\';
const breakPointFiles = root + \'/breakPointFiles\'; // 获取断点文件列表
const fileMetaData = root + \'/fileMetaData\'; // 新上传文件元数据,secUpload 属性用于判断是否可以秒传
const uploadPart = root +\'/uploadPart\'; // 分片上传,每片的上传接口
const checkIntegrity = root + \'/checkIntegrity\'; // 检查文件完整性
const fileInfoPos = root + \'/fileInfoPos\'; // 获取系统中所有已经上传的文件(调试)
const shardSize = 1024 * 1024 * 2; // 分片上传,每片大小 2M
const chunkSize = 1024 * 1024 * 4; // md5 计算每段大小 4M
const statusInfoMap = {\'0\':\'待上传\',\'1\':\'正在计算\',\'2\':\'正在上传\',\'3\':\'上传成功\',\'4\':\'上传失败\',\'5\':\'暂停上传\',\'6\':\'文件检查\'};
let uploadFiles = {}; //用于存储当前需要上传的文件列表 fileName=>fileInfo
$(function () {
// 用于调试 begin 加载系统中已经上传过的文件列表
$.ajax({
type:\'get\',
url:fileInfoPos,
dataType:\'json\',
success:function (res) {
let htmlCodes = [];
for(let i=0;i<res.length;i++){
htmlCodes.push(\'<tr>\');
htmlCodes.push(\'<td>\'+res[i].name+\'</td>\');
htmlCodes.push(\'<td>\'+res[i].size+\'</td>\');
htmlCodes.push(\'<td>\'+res[i].uploaded+\'</td>\');
htmlCodes.push(\'<td>\'+res[i].relativePath+\'</td>\');
htmlCodes.push(\'<td>\'+res[i].md5+\'</td>\');
htmlCodes.push(\'</tr>\')
}
$(\'table\').append(htmlCodes.join(\'\'))
}
})
// 用于调试 end
// 事件绑定
$(\'#file\').change(changeFiles); // 选择文件列表事件
$(\'#startUpload\').click(beginUpload); // 开始上传
$(\'#uploadfiles\').on(\'change\',\'input[type=file]\',breakPointFileChange); // 断点文件选择事件
// 初始化时加载断点文件
(function () {
$.ajax({
type:\'get\',
url:breakPointFiles,
dataType:\'json\',
success:function (files) {
if(files && files.length > 0){
for (let i=0;i<files.length;i++){
let fileId = id();
let process = parseFloat((files[i].uploaded / files[i].size ) * 100).toFixed(2);
$(\'#uploadfiles\').append(templateUploadItem(fileId,files[i],process,5,\'断点续传\',i+1));
uploadFiles[fileId] = {fileInfo:files[i],status:5};
}
}
}
})
})(window);
/**
* 文件重新选择事件
* @param e
*/
function changeFiles(e) {
// 检测文件列表是否符合要求,默认都符合
if(this.files.length == 0){return ;}
// 先把文件信息追加上去,不做检查也不上传
for (let i = 0; i < this.files.length; i++) {
let file = this.files[i];
let fileId = id();
$(\'#uploadfiles\').append(templateUploadItem(fileId,file,0,0,\'\'));
uploadFiles[fileId] = {file:file,status:0};
}
}
/**
* 断点文件选择文件事件
*/
function breakPointFileChange(e) {
let fileId = $(e.target).closest(\'li\').attr(\'fileId\');
if(this.files.length > 0){
uploadFiles[fileId].file = this.files[0];
}
}
/**
* 开始上传
*/
function beginUpload() {
// 先对每一个文件进行检查,除断点文件不需要检查外
// console.log(uploadFiles);
for(let fileId in uploadFiles){
// 如果断点文件没有 file 信息,直接失败
if(uploadFiles[fileId].status == 5 && !uploadFiles[fileId].file){
//断点文件一定有 fileInfo
let fileInfo = uploadFiles[fileId].fileInfo;
let $li = $(\'#uploadfiles\').find(\'li[fileId=\'+fileId+\']\');
$li.children(\'.upload-status\').text(\'上传失败\');fileInfo.status = 4;
$li.children(\'.tips\').text(\'无文件信息\');
continue;
}
if(uploadFiles[fileId].status == 5){
//如果断点文件有 file 信息,则可以直接断点续传了
let $li = $(\'#uploadfiles\').find(\'li[fileId=\'+fileId+\']\');
$li.children(\'.upload-status\').text(\'正在上传\');uploadFiles[fileId].status = 2;
startUpload(uploadFiles[fileId],$li);
continue;
}
//其它待上传的文件,先后台检查文件信息,再上传
if(uploadFiles[fileId].status == 0){
let $li = $(\'#uploadfiles\').find(\'li[fileId=\'+fileId+\']\');
uploadFiles[fileId].status = 1; $li.children(\'.upload-status\').text(\'正在计算\') //正在计算
checkFileItem(uploadFiles[fileId].file,function (res) {
if(res.message && res.message == \'fail\'){
$li.children(\'.upload-status\').text(res.returnCode || \'上传出错\');uploadFiles[fileId].status = 4;
}else{
uploadFiles[fileId].fileInfo = res;
if(res.secUpload){
$li.children(\'.upload-status\').text(\'文件秒传\');uploadFiles[fileId].status = 3;
$li.children(\'.upload-process\').text(\'100 %\');
}else{
$li.children(\'.upload-status\').text(\'正在上传\');uploadFiles[fileId].status = 2;
startUpload(uploadFiles[fileId],$li);
}
}
});
}
}
/**
* 计算 md5 值,请求后台查看是否可秒传
*/
function checkFileItem(file,callback) {
md5Hex(file,function (md5) {
$.ajax({
type:\'get\',
async:false,
url:fileMetaData,
data:{originFileName:file.name,fileSize:file.size,md5:md5},
dataType:\'json\',
success:callback
});
});
}
/**
* 开始正式上传单个文件
* */
function startUpload(uploadFile,$li) {
let file = uploadFile.file;
let offset = uploadFile.fileInfo.uploaded || 0;
let shardCount =Math.ceil((file.size - offset )/shardSize);
for(var i=0;i<shardCount;i++){
var start = i * shardSize + offset;
var end = Math.min(file.size,start + shardSize );//在file.size和start+shardSize中取最小值,避免切片越界
var filePart = file.slice(start,end);
var formData = new FormData();
formData.append("file",filePart,uploadFile.fileInfo.name || uploadFile.fileInfo.originFileName);
formData.append(\'relativePath\',uploadFile.fileInfo.relativePath);
$.ajax({
async:false,
url: uploadPart,
cache: false,
type: "POST",
data: formData,
dateType: \'json\',
processData: false,
contentType: false,
success:function (uploaded) {
//进度计算
let process = parseFloat((uploaded / file.size) * 100).toFixed(2);
console.log(file.name+\'|\'+process);
$li.find(\'.upload-process\').text(process + \'%\');
// 视觉进度
// $(\'.upload-item\').append("<style>.upload-item::before{ width:"+(process * 1000)+ "% }</style>");
if(uploaded == file.size){
// 上传完成后,检查文件完整性
$li.children(\'.upload-status\').text(\'文件检查\');
$.ajax({
type:\'get\',
async:false,
url:checkIntegrity,
data:{fileName:uploadFile.fileInfo.name || uploadFile.fileInfo.originFileName,fileSize:uploaded,relativePath:uploadFile.fileInfo.relativePath},
success:function (res) {
if(res.message != \'fail\'){
$li.children(\'.upload-status\').text(\'上传成功\');
}else{
$li.children(\'.upload-status\').text(\'上传失败\');
$li.children(\'.tips\').text(res.returnCode);
}
}
})
}
}
});
}
}
}
/**
* 创建模板 html 上传文件项
* @param fileName
* @param process
* @param status
* @param tips
* @returns {string}
*/
function templateUploadItem(fileId,fileInfo,process,status,tips,breakPoint) {
let htmlCodes = [];
htmlCodes.push(\'<li class="upload-item" fileId="\'+fileId+\'">\');
htmlCodes.push(\'<span class="file-name">\'+(fileInfo.name || fileInfo.originFileName)+\'</span>\');
htmlCodes.push(\'<span class="file-size">\'+(fileInfo.size)+\'</span>\');
htmlCodes.push(\'<span class="upload-process">\'+process+\' %</span>\');
htmlCodes.push(\'<span class="upload-status" >\'+statusInfoMap[status+\'\']+\'</span>\');
htmlCodes.push(\'<span class="tips">\'+tips+\'</span>\');
if(breakPoint){
htmlCodes.push(\'<input type="file" name="file" style="margin-left: 10px;"/>\');
}
htmlCodes.push(\'</li>\');
return htmlCodes.join(\'\');
}
/**
* 计算 md5 值(同步计算)
* @param file
*/
function md5Hex(file,callback) {
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = function (e) {
spark.append(e.target.result); // Append array buffer
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
let hash = spark.end();
callback(hash);
}
}
fileReader.onerror = function () {
console.warn(\'md5 计算时出错\');
};
function loadNext(){
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
}
function id() {
return Math.floor(Math.random() * 1000);
}
});
</script>
</body>
</html>
源码位置: https://gitee.com/sanri/example/tree/master/test-mvc
一点小推广
创作不易,希望可以支持下我的开源软件,及我的小工具,欢迎来 gitee 点星,fork ,提 bug 。
Excel 通用导入导出,支持 Excel 公式
博客地址:https://blog.csdn.net/sanri1993/article/details/100601578
gitee:https://gitee.com/sanri/sanri-excel-poi
使用模板代码 ,从数据库生成代码 ,及一些项目中经常可以用到的小工具
博客地址:https://blog.csdn.net/sanri1993/article/details/98664034
gitee:https://gitee.com/sanri/sanri-tools-maven