背景
大文件直传易受网络波动而失败,同时运维人员一直维护失败的队列不合理,需要实现分片上传,webuploader已经是很成熟的分片上传插件了,这里使用webuploader进行分片。
前端
分片通过常规配置可得,主要交互通过以下两个事件可得:
- beforeSend:分片发送前校验文件,分片若已存在则跳过(这里保证了失败重新传能快速跳过已上传部分)
- uploadSuccess:文件整体上传完成后进行分片的合并
WebUploader.Uploader.register({
"before-send":"beforeSend"
},{
beforeSend:function(block){
var deferred = WebUploader.Deferred();
$.ajax({
type: "POST",
url: '/checkChunk',
async: false,
data: {
//文件名称
fileName: block.file.name,
//当前分块下标
chunk: block.chunk,
//当前分块大小
chunkSize: block.end - block.start,
//总大小
totalSize: block.total
},
dataType: "json",
success: function(res){
if(res.code == 'YES'){
//分块存在,跳过
deferred.reject();
}else{
//分块不存在或不完整,重新发送该分块内容
deferred.resolve();
}
}
});
return deferred.promise();
}
});
var uploader = WebUploader.create({
auto: true, // 选完文件后,是否自动上传
resize: false, // 不压缩image
duplicate: false,
chunked: true, //分片处理
chunkSize: 25 * 1024 * 1024, //每片25M
chunkRetry: false,//如果失败,则不重试
threads:'1', //同时运行5个线程传输
fileNumLimit:'100', //文件总数量只能选择100个
swf: '/Uploader.swf', // swf文件路径
server: /uploadFile', // 文件接收服务端。
pick: '#upload', // 选择文件的按钮。可选
accept:{
title: '',
extensions: 'tif|tiff'
},
timeout: 0
});
// 文件上传成功
uploader.on('uploadSuccess', function(file) {
//如果分块上传成功,则通知后台合并分块
$.ajax({
type: "POST",
url: '/mergeChunk',
async: false,
data:{
//文件名称
fileName: file.name,
//总大小
totalSize: file.size
}
});
});
后端
控制器
Jfinal使用COS组件进行上传,由于getFile方法没有直接传路径得到File,所以要封装方法在控制器中
/**
* 改造getFile方法,避免NPE错误
* @return
*/
protected UploadFile getChunkFile(String uploadPath) {
List<UploadFile> uploadFiles = getFiles(uploadPath);
return uploadFiles.size() > 0 ? uploadFiles.get(0) : null;
}
通过三个方法进行文件处理:
- uploadFile:获取已上传的分片,通过JDK的renameTo方法进行位置移动及重命名(保证分片唯一性)
- checkChunk:检查分片,对比上传位置的分片及大小判断分片是否完全上传成功
- mergeChunk:合并分片,遍历分片文件夹,进行合并及文件夹删除
/**
* 上传文件
*/
public void uploadFile() {
//必须要getFile后才能使用getPara方法
UploadFile upload = getChunkFile(user.getId());
try {
Integer chunk = getParaToInt("chunk");
String name = getPara("name");
Long size = getParaToLong("size");
File file = upload.getFile();
File f = FileKit.renameChunk(file, chunk, name, size);
file.renameTo(f);
} catch (Exception e) {
// TODO
}
renderNull();
}
/**
* 检查分块
*/
public void checkChunk() {
String fileName = getPara("fileName");
Integer chunk = getParaToInt("chunk");
Long chunkSize = getParaToLong("chunkSize");
Long totalSize = getParaToLong("totalSize");
File chunkFile = FileKit.getChunkFile(user.getId(), fileName, totalSize, chunk);
// 检查文件是否存在,且大小是否一致
if(chunkFile.exists() && chunkFile.length() == chunkSize){
// 上传过
renderJson(YES);
}else{
// 存在分片则删除
if(chunkFile.exists()) {
chunkFile.delete();
}
renderJson(NO);
}
}
/**
* 合并分块
* @throws IOException
*/
public void mergeChunk() throws IOException {
String fileName = getPara("fileName");
Long totalSize = getParaToLong("totalSize");
//读取目录里的所有文件
File dir = FileKit.getChunkFile(user.getId(), fileName, totalSize, 0).getParentFile();
if(dir.exists()) {
File output = new File(dir.getParent(), fileName);
FileKit.mergeChunk(dir, output);
// TODO 后续处理
}
renderNull();
}
工具类FileKit
封装了三个主要方法:
- getChunkFile:生成新的文件路径(upload/账号ID/文件名_大小)
- renameChunk:重命名分片
- mergeChunk:合并分片的封装
/**
* 获取COS组件的分片
*
* @param userId 用户ID
* @param name 文件名
* @param total 文件大小
* @param chunk 分片序号
*
* @return 分片文件
*/
public static File getChunkFile(Long userId, String name, Long total, Integer chunk) {
String body = null;
int dot = name.lastIndexOf(".");
if (dot != -1) {
body = name.substring(0, dot);
} else {
body = name;
}
StringBuilder sb = new StringBuilder();
sb.append(Constant.SERVICE_UPLOAD_PATH);
sb.append(File.separator);
sb.append(userId);
sb.append(File.separator);
sb.append(body + "_" + total);
sb.append(File.separator);
sb.append(chunk);
return new File(sb.toString());
}
/**
* 重命名分片
* @param f 文件
* @return 文件
*/
public static File renameChunk(File f) {
//默认第一个为0
f = new File(f.getParent(), "0");
//后续累加
int count = 0;
while (f.exists() && count < 9999) {
count++;
f = new File(f.getParent(), String.valueOf(count));
}
return f;
}
/**
* 重命名分片
* @param f 文件
* @param chunk 分片序号
* @param name 文件名
* @param size 文件大小
* @return 文件
*/
public static File renameChunk(File f, Integer chunk, String name, Long size) {
//新的父文件
String body = null;
int dot = name.lastIndexOf(".");
if (dot != -1) {
body = name.substring(0, dot);
} else {
body = name;
}
File parent = new File(f.getParent(), body + "_" + size);
if (!parent.exists()) {
if (!parent.mkdirs()) {
return null;
}
}
return new File(parent, String.valueOf(chunk));
}
/**
* 合并分片文件
* @param dir 分片文件夹
* @param output 目标文件
* @throws IOException
*/
@SuppressWarnings("resource")
public static void mergeChunk(File dir, File output) throws IOException {
File[] fileArray = dir.listFiles(new FileFilter(){
//排除目录只要文件
@Override
public boolean accept(File pathname) {
if(pathname.isDirectory()){
return false;
}
return true;
}
});
//转成集合,便于排序
List<File> fileList = new ArrayList<File>(Arrays.asList(fileArray));
Collections.sort(fileList,new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
if(Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())){
return -1;
}
return 1;
}
});
// 目标文件安排
if(output.exists()) {
output.delete();
}
output.createNewFile();
//输出流
FileChannel outChnnel = new FileOutputStream(output).getChannel();
//合并
FileChannel inChannel;
for(File file : fileList) {
inChannel = new FileInputStream(file).getChannel();
inChannel.transferTo(0, inChannel.size(), outChnnel);
inChannel.close();
file.delete();
}
outChnnel.close();
//清除文件夹
if(dir.isDirectory() && dir.exists()){
dir.delete();
}
}
注意
这种分片方式不适用于集群的情况,因为负载均衡会导致分片分散在集群中,这里建议单独抽取文件上传模块成为一个单例应用,保证分片一致性