前言
断点续传:
在下载或上传时,将文件分段,每段采用一个线程进行上传或下载。
每次上传分块前校验分块,如果已存在分块则不再上传。
因此若碰到网络故障中断,可继续上传下载(检测是否在服务器存在,或是否下载)。
最后将每段合并。
流程图
上传前准备工作
WebUploader.js中有给文件生成MD5的方法,即在文件上传前,把内容读取出来,算出MD5值,通过Ajax与服务端合并完成后的文件MD5值进行比对验证。
上传前还需要检查该上传的文件是否存在。
前端
beforeSendFile:function(file) {
// 创建一个deffered,用于通知是否完成操作
var deferred = WebUploader.Deferred();
// 计算文件的唯一标识,用于断点续传
(new WebUploader.Uploader()).md5File(file, 0, 100*1024*1024)
.then(function(val) {
this.fileMd5 = val;
this.uploadFile = file;
//向服务端请求注册上传文件
$.ajax(
{
type:"POST",
url:"/api/media/upload/register",
data:{
// 文件唯一表示
fileMd5:this.fileMd5,
fileName: file.name,
fileSize:file.size,
mimetype:file.type,
fileExt:file.ext
},
dataType:"json",
success:function(response) {
if(response.success) {
//alert('上传文件注册成功开始上传');
deferred.resolve();
} else {
alert(response.message);
deferred.reject();
}
}
}
);
}.bind(this));
return deferred.promise();
}.bind(this),
后端
/**
* 得到文件所属目录路径
* 一级目录:MD5第一个字符
* 二级目录:MD5第二个字符
* 三级目录:MD5
* 文件名:MD5+文件扩展名
* @param fileMd5
* @return
*/
private String getFileFolderPath(String fileMd5) {
return uploadLocation + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
}
private String getFilePath(String fileMd5, String fileExt) {
return this.getFileFolderPath(fileMd5) + fileMd5 + "." + fileExt;
}
private String getChunkFileFolderPath(String fileMd5) {
return this.getFileFolderPath(fileMd5) + "chunk/";
}
// 文件上传前的准备工作
public ResponseResult register(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
// 检查文件是否存在于磁盘
String fileFolderPath = this.getFileFolderPath(fileMd5);
String filePath = this.getFilePath(fileMd5, fileExt);
File file = new File(filePath);
boolean exists = file.exists();
// 检查文件是否存在于MongoDB (主键为 fileMd5)
boolean present = mediaFileRepository.findById(fileMd5).isPresent();
if (exists && present) {
// 既存在于磁盘又存在于数据库说明该文件存在
ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_EXIST);
}
// 文件不存在
// 检查文件所在目录是否存在
File fileFolder = new File(fileFolderPath);
if (!fileFolder.exists()) {
// 不存在创建该目录 (目录就是根据前端传来的MD5值创建的)
fileFolder.mkdirs();
}
return ResponseResult.SUCCESS();
}
检查分块是否存在
分块名是从0开始递增的(相当于索引),通过分块所在路径+分块的索引定位到具体分块来判断是否存在。
前端
beforeSend:function(block) {
var deferred = WebUploader.Deferred();
// 每次上传分块前校验分块,如果已存在分块则不再上传,达到断点续传的目的
$.ajax(
{
type:"POST",
url:"/api/media/upload/checkchunk",
data:{
// 文件唯一表示
fileMd5:this.fileMd5,
// 当前分块下标
chunk:block.chunk,
// 当前分块大小
chunkSize:block.end-block.start
},
dataType:"json",
success:function(response) {
if(response.fileExist) {
// 分块存在,跳过该分块
deferred.reject();
} else {
// 分块不存在或不完整,重新发送
deferred.resolve();
}
}
}
);
//构建fileMd5参数,上传分块时带上fileMd5
this.uploader.options.formData.fileMd5 = this.fileMd5;
this.uploader.options.formData.chunk = block.chunk;
return deferred.promise();
}.bind(this),
后端
// 检查分块是否存在
public CheckChunkResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) {
// 检查分块文件是否存在
// 得到分块所在路径
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
// 分块所在路径+分块的索引可定位具体分块
if (new File(chunkFileFolderPath + chunk).exists()) {
return new CheckChunkResult(MediaCode.CHUNK_FILE_EXIST_CHECK, true);
}
return new CheckChunkResult(CommonCode.SUCCESS, false);
}
上传文件
我们选择分块上传文件,chunked设置为true
上传文件使用的是IOUtils.copy(in, out);
方法
// 上传分块
public ResponseResult uploadChunk(MultipartFile file, String fileMd5, Integer chunk) {
// 检查分块目录是否存在
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
File chunkFileFolder = new File(chunkFileFolderPath);
if (!chunkFileFolder.exists()) {
chunkFileFolder.mkdirs();
}
// 上传文件输入流
InputStream inputStream = null;
FileOutputStream outputStream = null;
try {
inputStream = file.getInputStream();
outputStream = new FileOutputStream(new File(chunkFileFolderPath + chunk));
IOUtils.copy(inputStream, outputStream);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return ResponseResult.SUCCESS();
}
分块的合并与校验
全部分块上传后,我们就要进行合并分块的工作、校验、与入库。
合并分块:使用RandomAccessFile
类进行读写操作,将排序好的分块列表遍历写入到合并文件。
校验:就是将前端WebUploader所生成的MD5与后端合并好的文件所生成的MD5进行比对。
// 合并分块、校验MD5、数据入库
public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
// 1. 合并分块
String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
File chunkFileFolder = new File(chunkFileFolderPath);
File[] files = chunkFileFolder.listFiles();
String filePath = this.getFilePath(fileMd5, fileExt);
File mergeFile = new File(filePath);
List<File> fileList = Arrays.asList(files);
// 合并
mergeFile = this.mergeFile(fileList, mergeFile);
if (mergeFile == null) {
ExceptionCast.cast(MediaCode.MERGE_FILE_FAIL);
}
// 2. 校验文件MD5是否与前端传入一致
boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
// 校验失败
if (!checkResult) {
ExceptionCast.cast(MediaCode.MERGE_FILE_CHECKFAIL);
}
// 3. 文件信息写入MongoDB
MediaFile mediaFile = new MediaFile();
mediaFile.setFileId(fileMd5);
mediaFile.setFileOriginalName(fileName);
mediaFile.setFileName(fileMd5 + "." + fileExt);
// 文件相对路径
String relativeFilePath = fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2)+ "/" + fileMd5 + "/";
mediaFile.setFilePath(relativeFilePath);
mediaFile.setFileSize(fileSize);
mediaFile.setUploadTime(new Date());
mediaFile.setMimeType(mimetype);
mediaFile.setFileType(fileExt);
// 上传成功状态码
mediaFile.setFileStatus(UPLOADED);
mediaFileRepository.save(mediaFile);
return ResponseResult.SUCCESS();
}
/**
* 合并文件
* @param chunkFileList
* @param mergeFile
* @return
*/
private File mergeFile(List<File> chunkFileList, File mergeFile) {
try {
// 有删 无创建
if (mergeFile.exists()) {
mergeFile.delete();
} else {
mergeFile.createNewFile();
}
// 排序
Collections.sort(chunkFileList, new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
if (Integer.parseInt(o1.getName()) > Integer.parseInt(o2.getName())) {
return 1;
}
return -1;
}
});
byte[] b = new byte[1024];
RandomAccessFile writeFile = new RandomAccessFile(mergeFile, "rw");
for (File chunkFile : chunkFileList) {
RandomAccessFile readFile = new RandomAccessFile(chunkFile, "r");
int len = -1;
while ((len = readFile.read(b)) != -1) {
writeFile.write(b, 0, len);
}
readFile.close();
}
writeFile.close();
return mergeFile;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
/**
* 校验文件MD5
* @param mergeFile
* @param md5
* @return
*/
private boolean checkFileMd5(File mergeFile, String md5) {
try {
// 得到文件MD5
FileInputStream inputStream = new FileInputStream(mergeFile);
String md5Hex = DigestUtils.md5Hex(inputStream);
if (StringUtils.equalsIgnoreCase(md5, md5Hex)) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
效果测试
在上传过程中,关闭上传页面(中断上传)再次上传,在checkchunk请求时发现分块都已存在,继续checkchunk直到分块不存在再进行uploadchunk。
分块全部上传完会请求合并,成功后提示上传成功。
磁盘
数据库
主键为文件的MD5值
而当上传相同文件的时候,因为在register的准备工作中做过了判断,因为会上传不成功。
BUG
有一点比较迷的是,其他文件都能上传成功,而我的英雄学院这集动画上传后在磁盘中分块和合并视频都存在,而且也能正常播放,但是后端生成的md5码就是与前端的不同,导致返回上传不成功的状态码。
然后我找了个在线生成文件MD5码的网站:
和后端生成的一样,那说明问题出在前端(没错本人前端cv工程师)。
然后我再看了下前端的生成md5的方法:
md5File(file, 0, 100*1024*1024)
好吧这里做了文件大小的限制,而动漫视频么,挺大的,把它设置大点后,生成的md5就与后端相同了,视频上传成功。
md5File( file[, start[, end]] ) ⇒ promise
md5File: function( file, start, end ) {
var md5 = new Md5(),
deferred = Base.Deferred(),
blob = (file instanceof Blob) ? file :
this.request( 'get-file', file ).source;
md5.on( 'progress load', function( e ) {
e = e || {};
deferred.notify( e.total ? e.loaded / e.total : 1 );
});
md5.on( 'complete', function() {
deferred.resolve( md5.getResult() );
});
md5.on( 'error', function( reason ) {
deferred.reject( reason );
});
if ( arguments.length > 1 ) {
start = start || 0;
end = end || 0;
start < 0 && (start = blob.size + start);
end < 0 && (end = blob.size + end);
end = Math.min( end, blob.size );
blob = blob.slice( start, end );
}
md5.loadFromBlob( blob );
return deferred.promise();
}