大文件批量上传断点续传文件秒传
接上篇文章 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