一、 功能性需求与非功能性需求
要求操作便利,一次选择多个文件和文件夹进行上传;
支持PC端全平台操作系统,Windows,Linux,Mac
支持文件和文件夹的批量下载,断点续传。刷新页面后继续传输。关闭浏览器后保留进度信息。
支持文件夹批量上传下载,服务器端保留文件夹层级结构,服务器端文件夹层级结构与本地相同。
支持大文件批量上传(20G)和下载,同时需要保证上传期间用户电脑不出现卡死等体验;
支持文件夹上传,文件夹中的文件数量达到1万个以上,且包含层级结构。
支持断点续传,关闭浏览器或刷新浏览器后仍然能够保留进度。
支持文件夹结构管理,支持新建文件夹,支持文件夹目录导航
交互友好,能够及时反馈上传的进度;
服务端的安全性,不因上传文件功能导致JVM内存溢出影响其他功能使用;
最大限度利用网络上行带宽,提高上传速度;
二、 设计分析
对于大文件的处理,无论是用户端还是服务端,如果一次性进行读取发送、接收都是不可取,很容易导致内存问题。所以对于大文件上传,采用切块分段上传
从上传的效率来看,利用多线程并发上传能够达到最大效率。
三、解决方案:
文件上传页面的前端可以选择使用一些比较好用的上传组件,例如百度的开源组件WebUploader,泽优软件的up6,这些组件基本能满足文件上传的一些日常所需功能,如异步上传文件,文件夹,拖拽式上传,黏贴上传,上传进度监控,文件缩略图,甚至是大文件断点续传,大文件秒传。
在web项目中上传文件夹现在已经成为了一个主流的需求。在OA,或者企业ERP系统中都有类似的需求。上传文件夹并且保留层级结构能够对用户行成很好的引导,用户使用起来也更方便。能够提供更高级的应用支撑。
文件夹数据表结构
TABLE IF NOT EXISTS `up6_folders` (
char(32) NOT NULL ,
varchar(255) default '',
char(32) default '',
int(11) default '0',
bigint(19) default '0',
varchar(50) default '0',
varchar(255) default '',
varchar(255) default '',
varchar(255) default '',
int(11) default '0',
int(11) default '0',
int(11) default '0',
default '0',
default '0',
timestamp NULL default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP,
char(32) default '',
PRIMARY KEY (`f_id`)
DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
文件数据表结构
TABLE IF NOT EXISTS `up6_files` (
char(32) NOT NULL,
char(32) default '', /*父级文件夹ID*/
char(32) default '', /*根级文件夹ID*/
default '0', /*是否是一条文件夹信息*/
default '0', /*是否是文件夹中的文件*/
int(11) default '0',
varchar(255) default '', /*文件在本地的名称(原始文件名称)*/
varchar(255) default '', /*文件在服务器的名称*/
varchar(512) default '', /*文件在本地的路径*/
varchar(512) default '', /*文件在远程服务器中的位置*/
varchar(512) default '',
varchar(40) default '', /*文件MD5*/
bigint(19) default '0', /*文件大小*/
varchar(10) default '0', /*文件大小(格式化的)*/
bigint(19) default '0', /*续传位置*/
bigint(19) default '0', /*已上传大小*/
varchar(7) default '0%', /*已上传百分比*/
default '0', /*是否已上传完毕*/
timestamp NULL default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP,
default '0',
default '0',
PRIMARY KEY (`f_id`)
DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
该项目核心就是文件分块上传。前后端要高度配合,需要双方约定好一些数据,才能完成大文件分块,我们在项目中要重点解决的以下问题。
* 如何分片;
* 如何合成一个文件;
* 中断了从哪个分片开始。
如何分,利用强大的js库,来减轻我们的工作,市场上已经能有关于大文件分块的轮子,虽然程序员的天性曾迫使我重新造轮子。但是因为时间的关系还有工作的关系,我只能罢休了。最后我选择了百度的WebUploader来实现前端所需。
如何合,在合之前,我们还得先解决一个问题,我们如何区分分块所属那个文件的。刚开始的时候,我是采用了前端生成了唯一uuid来做文件的标志,在每个分片请求上带上。不过后来在做秒传的时候我放弃了,采用了Md5来维护分块和文件关系。
在服务端合并文件,和记录分块的问题,在这方面其实行业已经给了很好的解决方案了。参考迅雷,你会发现,每次下载中的时候,都会有两个文件,一个文件主体,另外一个就是文件临时文件,临时文件存储着每个分块对应字节位的状态。
这些都是需要前后端密切联系才能做好,前端需要根据固定大小对文件进行分片,并且请求中要带上分片序号和大小。前端发送请求顺利到达后台后,服务器只需要按照请求数据中给的分片序号和每片分块大小(分片大小是固定且一样的)算出开始位置,与读取到的文件片段数据,写入文件即可。
为了便于开发,我 将服务端的业务逻辑进行了如下划分,分成初始化,块处理,文件上传完毕等。
服务端的业务逻辑模块如下
![点击并拖拽以移动](/i/l/?n=22&i=blog/2441432/202210/2441432-20221025115732487-287956078.gif)
功能分析:
文件夹生成模块
![点击并拖拽以移动](/i/l/?n=22&i=blog/2441432/202210/2441432-20221025115732487-287956078.gif)
文件夹上传完毕后由服务端进行扫描代码如下
class fd_scan
DbHelper db;
Connection con;
null;
null;
public FileInf root = null;//根节点
public fd_scan()
{
this.db = new DbHelper();
this.con = this.db.GetCon();
}
public void makeCmdF()
{
new StringBuilder();
"insert into up6_files (");
" f_id");//1
",f_pid");//2
",f_pidRoot");//3
",f_fdTask");//4
",f_fdChild");//5
",f_uid");//6
",f_nameLoc");//7
",f_nameSvr");//8
",f_pathLoc");//9
",f_pathSvr");//10
",f_pathRel");//11
",f_md5");//12
",f_lenLoc");//13
",f_sizeLoc");//14
",f_lenSvr");//15
",f_perSvr");//16
",f_complete");//17
") values(");
" ?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
")");
try {
this.cmd_add_f = this.con.prepareStatement(sb.toString());
this.cmd_add_f.setString(1, "");//id
this.cmd_add_f.setString(2, "");//pid
this.cmd_add_f.setString(3, "");//pidRoot
this.cmd_add_f.setBoolean(4, true);//fdTask
this.cmd_add_f.setBoolean(5, false);//f_fdChild
this.cmd_add_f.setInt(6, 0);//f_uid
this.cmd_add_f.setString(7, "");//f_nameLoc
this.cmd_add_f.setString(8, "");//f_nameSvr
this.cmd_add_f.setString(9, "");//f_pathLoc
this.cmd_add_f.setString(10, "");//f_pathSvr
this.cmd_add_f.setString(11, "");//f_pathRel
this.cmd_add_f.setString(12, "");//f_md5
this.cmd_add_f.setLong(13, 0);//f_lenLoc
this.cmd_add_f.setString(14, "");//f_sizeLoc
this.cmd_add_f.setLong(15, 0);//f_lenSvr
this.cmd_add_f.setString(16, "");//f_perSvr
this.cmd_add_f.setBoolean(17, true);//f_complete
catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void makeCmdFD()
{
new StringBuilder();
"insert into up6_folders (");
" f_id");//1
",f_pid");//2
",f_pidRoot");//3
",f_nameLoc");//4
",f_uid");//5
",f_pathLoc");//6
",f_pathSvr");//7
",f_pathRel");//8
",f_complete");//9
") values(");//
" ?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
",?");
")");
try {
this.cmd_add_fd = this.con.prepareStatement(sb.toString());
this.cmd_add_fd.setString(1, "");//id
this.cmd_add_fd.setString(2, "");//pid
this.cmd_add_fd.setString(3, "");//pidRoot
this.cmd_add_fd.setString(4, "");//name
this.cmd_add_fd.setInt(5, 0);//f_uid
this.cmd_add_fd.setString(6, "");//pathLoc
this.cmd_add_fd.setString(7, "");//pathSvr
this.cmd_add_fd.setString(8, "");//pathRel
this.cmd_add_fd.setBoolean(9, true);//complete
catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
protected void GetAllFiles(FileInf inf,String root)
{
new File(inf.pathSvr);
File [] allFile = dir.listFiles();
for(int i = 0; i < allFile.length; i++)
{
if(allFile[i].isDirectory())
{
new FileInf();
String uuid = UUID.randomUUID().toString();
"-", "");
fd.id = uuid;
fd.pid = inf.id;
this.root.id;
fd.nameSvr = allFile[i].getName();
fd.nameLoc = fd.nameSvr;
fd.pathSvr = allFile[i].getPath();
"\\", "/");
fd.pathRel = fd.pathSvr.substring(root.length() + 1);
"100%";
true;
this.save_folder(fd);
this.GetAllFiles(fd, root);
}
else
{
new FileInf();
String uuid = UUID.randomUUID().toString();
"-", "");
fl.id = uuid;
fl.pid = inf.id;
this.root.id;
fl.nameSvr = allFile[i].getName();
fl.nameLoc = fl.nameSvr;
fl.pathSvr = allFile[i].getPath();
"\\", "/");
fl.pathRel = fl.pathSvr.substring(root.length() + 1);
fl.lenSvr = allFile[i].length();
fl.lenLoc = fl.lenSvr;
"100%";
true;
this.save_file(fl);
}
}
}
protected void save_file(FileInf f)
{
try {
this.cmd_add_f.setString(1, f.id);//id
this.cmd_add_f.setString(2, f.pid);//pid
this.cmd_add_f.setString(3, f.pidRoot);//pidRoot
this.cmd_add_f.setBoolean(4, f.fdTask);//fdTask
this.cmd_add_f.setBoolean(5, true);//f_fdChild
this.cmd_add_f.setInt(6, f.uid);//f_uid
this.cmd_add_f.setString(7, f.nameLoc);//f_nameLoc
this.cmd_add_f.setString(8, f.nameSvr);//f_nameSvr
this.cmd_add_f.setString(9, f.pathLoc);//f_pathLoc
this.cmd_add_f.setString(10, f.pathSvr);//f_pathSvr
this.cmd_add_f.setString(11, f.pathRel);//f_pathRel
this.cmd_add_f.setString(12, f.md5);//f_md5
this.cmd_add_f.setLong(13, f.lenLoc);//f_lenLoc
this.cmd_add_f.setString(14, f.sizeLoc);//f_sizeLoc
this.cmd_add_f.setLong(15, f.lenSvr);//f_lenSvr
this.cmd_add_f.setString(16, f.perSvr);//f_perSvr
this.cmd_add_f.setBoolean(17, f.complete);//f_complete
this.cmd_add_f.executeUpdate();
catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
//
}
protected void save_folder(FileInf f)
{
try {
this.cmd_add_fd.setString(1, f.id);//id
this.cmd_add_fd.setString(2, f.pid);//pid
this.cmd_add_fd.setString(3, f.pidRoot);//pidRoot
this.cmd_add_fd.setString(4, f.nameSvr);//name
this.cmd_add_fd.setInt(5, f.uid);//f_uid
this.cmd_add_fd.setString(6, f.pathLoc);//pathLoc
this.cmd_add_fd.setString(7, f.pathSvr);//pathSvr
this.cmd_add_fd.setString(8, f.pathRel);//pathRel
this.cmd_add_fd.setBoolean(9, f.complete);//complete
this.cmd_add_fd.executeUpdate();
catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void scan(FileInf inf, String root) throws IOException, SQLException
{
this.makeCmdF();
this.makeCmdFD();
this.GetAllFiles(inf, root);
this.cmd_add_f.close();
this.cmd_add_fd.close();
this.con.close();
}
分块上传,分块处理逻辑应该是最简单的逻辑了,up6已经将文件进行了分块,并且对每个分块数据进行了标识,这些标识包括文件块的索引,大小,偏移,文件MD5,文件块MD5(需要开启)等信息,服务端在接收这些信息后便可以非常方便的进行处理了。比如将块数据保存到分布式存储系统中
分块上传可以说是我们整个项目的基础,像断点续传、暂停这些都是需要用到分块。
分块这块相对来说比较简单。前端是采用了webuploader,分块等基础功能已经封装起来,使用方便。
借助webUpload提供给我们的文件API,前端就显得异常简单。
前台HTML模板
function()
var acx = "";
'<div class="file-item" id="tmpFile" name="fileItem">\
程序开发.pdf</div>\
</div>\
</div>\
取消"><img name="stop" src="js/stop.png"/><div>取消</div></span>\
继续"><img name="post" src="js/post.png"/><div>继续</div></span>\
停止"><img name="stop" src="js/stop.png"/><div>停止</div></span>\
删除"><img name="del" src="js/del.png"/><div>删除</div></span>\
;
'</div>';
'<div class="file-item" name="folderItem">\
程序开发.pdf</div>\
</div>\
</div>\
取消"><img name="stop" src="js/stop.png"/><div>取消</div></span>\
继续"><img name="post" src="js/post.png"/><div>继续</div></span>\
停止"><img name="stop" src="js/stop.png"/><div>停止</div></span>\
删除"><img name="del" src="js/del.png"/><div>删除</div></span>\
;
'</div>';
'<div class="files-panel" name="post_panel">\
选择多个文件</span>\
选择文件夹</span>\
粘贴文件和目录</span>\
安装控件</span>\
</div>\
</div>\
清除已完成文件</span>\
</div>\
;
return acx;
};
分则必合。把大文件分片了,但是分片了就没有原本文件功能,所以我们要把分片合成为原本的文件。我们只需要把分片按原本位置写入到文件中去。因为前面原理那一部我们已经讲到了,我们知道分块大小和分块序号,我就可以知道该分块在文件中的起始位置。所以这里使用RandomAccessFile是明智的,RandomAccessFile能在文件里面前后移动。但是在andomAccessFile的绝大多数功能,已经被JDK1.4的NIO的“内存映射文件(memory-mapped files)”取代了。我在该项目中分别写了使用RandomAccessFile与MappedByteBuffer来合成文件。分别对应的方法是uploadFileRandomAccessFile和uploadFileByMappedByteBuffer。两个方法代码如下。
秒传功能
服务端逻辑
秒传功能,相信大家都体现过了,网盘上传的时候,发现上传的文件秒传了。其实原理稍微有研究过的同学应该知道,其实就是检验文件MD5,记录下上传到系统的文件的MD5,在一个文件上传前先获取文件内容MD5值或者部分取值MD5,然后在匹配系统上的数据。
Breakpoint-http实现秒传原理,客户端选择文件之后,点击上传的时候触发获取文件MD5值,获取MD5后调用系统一个接口(/index/checkFileMd5),查询该MD5是否已经存在(我在该项目中用redis来存储数据,用文件MD5值来作key,value是文件存储的地址。)接口返回检查状态,然后再进行下一步的操作。相信大家看代码就能明白了。
嗯,前端的MD5取值也是用了webuploader自带的功能,这还是个不错的工具。
控件计算完文件MD5后会触发md5_complete事件,并传值md5,开发者只需要处理这个事件即可,
断点续传
up6已经自动对断点续传进行了处理,不需要开发都再进行单独的处理。
在f_post.jsp中接收这些参数,并进行处理,开发者只需要关注业务逻辑,不需要关注其它的方面。
断点续传,就是在文件上传的过程中发生了中断,人为因素(暂停)或者不可抗力(断网或者网络差)导致了文件上传到一半失败了。然后在环境恢复的时候,重新上传该文件,而不至于是从新开始上传的。
前面也已经讲过,断点续传的功能是基于分块上传来实现的,把一个大文件分成很多个小块,服务端能够把每个上传成功的分块都落地下来,客户端在上传文件开始时调用接口快速验证,条件选择跳过某个分块。
实现原理,就是在每个文件上传前,就获取到文件MD5取值,在上传文件前调用接口(/index/checkFileMd5,没错也是秒传的检验接口)如果获取的文件状态是未完成,则返回所有的还没上传的分块的编号,然后前端进行条件筛算出哪些没上传的分块,然后进行上传。
当接收到文件块后就可以直接写入到服务器的文件中
这是文件夹上传完后的效果
![点击并拖拽以移动](/i/l/?n=22&i=blog/2441432/202210/2441432-20221025115732487-287956078.gif)
这是文件夹上传完后在服务端的存储结构
![点击并拖拽以移动](/i/l/?n=22&i=blog/2441432/202210/2441432-20221025115732487-287956078.gif)
示例代码下载地址:up6-jsp-springboot: Web大文件上传-jsp-springboot示例 - Gitee.com