文件上传在web应用中是比较常见的功能,前段时间做了一个多文件、大文件、多线程文件上传的功能,使用效果还不错,总结分享下。
一、 功能性需求与非功能性需求
要求操作便利,一次选择多个文件进行上传;
支持大文件上传(1G),同时需要保证上传期间用户电脑不出现卡死等体验;
交互友好,能够及时反馈上传的进度;
服务端的安全性,不因上传文件功能导致JVM内存溢出影响其他功能使用;
最大限度利用网络上行带宽,提高上传速度;
二、 设计分析
对于大文件的处理,无论是用户端还是服务端,如果一次性进行读取发送、接收都是不可取,很容易导致内存问题。所以对于大文件上传,采用切块分段上传
从上传的效率来看,利用多线程并发上传能够达到最大效率。
对于大文件切块、多线程上传,需要考虑服务端合并文件的时间点;
三、解决方案:
在HTML5之前的标准是无法支持上面的功能,因此我们需要把功能实现居于H5提供的新特性上面:
1. H5新标准对file标签进行了增强,支持同时选择多个文件
<input type="file" multiple=true onchange="doSomething(this.files)"/>
1
注意multiple属性,设置为true;
onchange:一般是选择文件确定后的响应事件
this.files:文件对象集合
2. File对象
H5提供的类似java的RandomAccessFile的文件操作对象,其中silce方法允许程序指定文件的起止字节进行读取。利用这个对象,实现对大文件的切分;
3.
这个对象大家应该很熟悉了,属于web2.0的标准,我们最常用的ajax请求底层就是居于此对象。本质上是一个线程对象,因此我们通过创建一定数量的对象,实现多线程并行操作;
4. FormData对象
H5新增对象,可以理解为一个key-value的map,通过把文件的二进制流和业务参数封装到此对象,再交由对象发送到服务端,服务端可以通过普通的request.getParamter方法获取这些参数;
5. progress标签
H5新增的标签,在页面显示一个进度条:
value:当前进度条的值
max:最大值
利用这个标签,结合的回调来反馈目前上传的进度
四、客户端代码示例
HTML代码:
<input type="file" multiple=true onchange="showFileList(this.files)"/>
<input id="uploadBtn" type="button" value="上传" onclick="doUpload()"/>
1
2
java脚本:
var quence = new Array();//待上传的文件队列,包含切块的文件
/**
* 用户选择文件之后的响应函数,将文件信息展示在页面,同时对大文件的切块大小、块的起止进行计算、入列等
*/
function showFileList(files) {
if(!files) {
return;
}
var chunkSize = 5 * 1024 * 1024; //切块的阀值:5M
$(files).each(function(idx,e){
//展示文件列表,略......
if(e.size > chunkSize) {//文件大于阀值,进行切块
//切块发送
var chunks = Math.max(Math.floor(fileSize / chunkSize), 1)+1;//分割块数
for(var i=0 ; i<chunks; i++) {
var startIdx = i*chunkSize;//块的起始位置
var endIdx = startIdx+chunkSize;//块的结束位置
if(endIdx > fileSize) {
endIdx = fileSize;
}
var lastChunk = false;
if(i == (chunks-1)) {
lastChunk = true;
}
//封装成一个task,入列
var task = {
file:e,
uuid:uuid,//避免文件的重名导致服务端无法定位文件,需要给每个文件生产一个UUID
chunked:true,
startIdx:startIdx,
endIdx:endIdx,
currChunk:i,
totalChunk:chunks
}
quence.push(task);
}
} else {//文件小于阀值
var task = {
file:e,
uuid:uuid,
chunked:false
}
quence.push(task);
}
});
}
/**
* 上传器,绑定一个对象,处理分配给其的上传任务
**/
function Uploader(name) {
this.url=""; //服务端处理url
this.req = new ();
this.tasks; //任务队列
this.taskIdx = 0; //当前处理的tasks的下标
this.name=name;
this.status=0; //状态,0:初始;1:所有任务成功;2:异常
//上传 动作
this.upload = function(uploader) {
this.req.responseType = "json";
//注册load事件(即一次异步请求收到服务端的响应)
this.req.addEventListener("load", function(){
//更新对应的进度条
progressUpdate(this.response.uuid, this.response.fileSize);
//从任务队列中取一个再次发送
var task = uploader.tasks[uploader.taskIdx];
if(task) {
console.log(uploader.name + ":当前执行的任务编号:" +uploader.taskIdx);
this.open("POST", uploader.url);
this.send(uploader.buildFormData(task));
uploader.taskIdx++;
} else {
console.log("处理完毕");
uploader.status=1;
}
});
//处理第一个
var task = this.tasks[this.taskIdx];
if(task) {
console.log(uploader.name + ":当前执行的任务编号:" +this.taskIdx);
this.req.open("POST", this.url);
this.req.send(this.buildFormData(task));
this.taskIdx++;
} else {
uploader.status=1;
}
}
//提交任务
this.submit = function(tasks) {
this.tasks = tasks;
}
//构造表单数据
this.buildFormData = function(task) {
var file = task.file;
var formData = new FormData();
formData.append("fileName", file.name);
formData.append("fileSize", file.size);
formData.append("uuid", task.uuid);
var chunked = task.chunked;
if(chunked) {//分块
formData.append("chunked", task.chunked);
formData.append("data", file.slice(task.startIdx, task.endIdx));//截取文件块
formData.append("currChunk", task.currChunk);
formData.append("totalChunk", task.totalChunk);
} else {
formData.append("data", file);
}
return formData;
}
}
/**
*用户点击“上传”按钮
*/
function doUpload() {
//创建4个Uploader上传器(4条线程)
var uploader0 = new Uploader("uploader0");
var task0 = new Array();
var uploader1 = new Uploader("uploader1");
var task1 = new Array();
var uploader2 = new Uploader("uploader2");
var task2 = new Array();
var uploader3 = new Uploader("uploader3");
var task3 = new Array();
//将文件列表取模hash,分配给4个上传器
for(var i=0 ; i<quence.length; i++) {
if(i%4==0) {
task0.push(quence[i]);
} else if(i%4==1) {
task1.push(quence[i]);
} else if(i%4==2) {
task2.push(quence[i]);
} else if(i%4==3) {
task3.push(quence[i]);
}
}
/提交任务,启动线程上传
uploader0.submit(task0);
uploader0.upload(uploader0);
uploader1.submit(task1);
uploader1.upload(uploader1);
uploader2.submit(task2);
uploader2.upload(uploader2);
uploader3.submit(task3);
uploader3.upload(uploader3);
//注册一个定时任务,每2秒监控文件是否都上传完毕
uploadCompleteMonitor = setInterval("uploadComplete()",2000);
}
五、服务端处理:
服务端处理逻辑相对比较传统,利用输入输出流、NIO等把文件写到磁盘即可。
这里需要特别考虑的是关于被切块文件的合并。前端在上传的时候,文件块是无序到达服务端,因此我们在每次接收到一个文件块的时候需要判断被切块的文件是否都传输完毕并进行合并,思路如下:
回到前端,我们在构造被切块的文件formData的数据结构:
formData.append("fileName", file.name);
formData.append("fileSize", file.size);
formData.append("uuid", task.uuid);
formData.append("chunked", task.chunked);
formData.append("data", file.slice(task.startIdx, task.endIdx));//截取文件块
formData.append("currChunk", task.currChunk);
formData.append("totalChunk", task.totalChunk);
fileName:文件的原始名字
fileSize:文件的大小,KB
uuid:文件的uuid
chunked:true,标识是分段上传的文件块
data:文件二进制流
currChunk:当前上传的块编号
totalChunk:总块数
服务端以文件的UUID为key,维护一个chunk计数器,每接收到一块就找到对应的uuid执行计数器+1,同时考虑到并发情况,需采用同步关键字,避免出现逻辑错误。当计数器等于totalChunk的时候,进行文件合并
前端效果:
文件上传存储目录:D:\wamp64\www\up6\db\upload\2019\04\19\920144c756af424ca59136be71cf9209
文件上传完成后,被完整的存放在了目录中。
DEMO下载地址:https://dwz.cn/fgXtRtnu