⽤SpringBoot实现断点续传(HTTP)
需求:
项⽬要⽀持⼤⽂件上传功能,经过讨论,初步将⽂件上传⼤⼩控制在20G内,因此⾃⼰需要在项⽬中进⾏⽂件上传部分的调整和配置,⾃⼰将⼤⼩都以20G来进⾏限制。
PC端全平台⽀持,要求⽀持Windows,Mac,Linux
⽀持所有浏览器
⽀持⽂件批量上传
⽀持⽂件夹上传,且要求在服务端保留层级结构。⽂件夹数量要求⽀持到10W。
⽀持⼤⽂件断点续传,要求刷新浏览器,重启浏览器,重启电脑后仍然能够继续上传。⽂件⼤⼩要求能够⽀持到20个G。
⽀持⾃动加载本地⽂件,要求能够⾃动加载指定的本地⽂件。
⽀持⽂件批量下载,要求不要在服务器打包。因为20G的⽂件在服务器打包时间⽐较长。
⽀持⽂件夹下载,要求不要在服务器打包,下载到本地后要求保留层级结构
⽂件列表⾯板⽀持路径导航,新建⽂件夹
⼀. ⼤⽂件上传基础描述:
  各种WEB框架中,对于浏览器上传⽂件的请求,都有⾃⼰的处理对象负责对Http MultiPart协议内容进⾏解析,并供开发⼈员调⽤请求的表单内容。
⽐如:
Spring 框架中使⽤类似CommonsMultipartFile对象处理表⼆进制⽂件信息。
⽽.NET 中使⽤HtmlInputFile/ HttpPostedFile对象处理⼆进制⽂件信息。
优点:使⽤框架内置对象可以很⽅便的处理来⾃浏览器的MultiPart⼆进制信息请求,协议分析操作不⽤开发⼈员参与。
缺点:其接收数据包过程完全被封闭在框架内置对象中,直到本次请求信息处理(接收)完毕后,才允许开发⼈员从接⼝调取表单及⽂件内容。上传过程中的进度信息⽆法访问,⽆法上传⼤尺⼨⽂件(⽐如⼏百兆以上的⼤⽂件⼆进制信息)。
⽬标:我们要在JAVA WEB框架中,依靠Filter过滤器的能⼒,实现不依靠框架内置对象,从浏览器请求字节流中解析MultiPart协议,取得本次⽤户请求的所有信息,包括多⼆进制⽂件信息及其他表单项信息。⽤户上传的⽂件尺⼨将不受限制。⽽且在传输过程中,我们可以实时获得当前传输进度信息。
注:.NET框架中可依靠IHttpModule接⼝对象达到JAVA框架中Filter的能⼒,本⽂不做描述。
之前仿造写了⼀个HTML5版的⽂件上传插件,没看过的朋友可以先看⼀下~得到了不少朋友的好评,我⾃⼰也⽤在了项⽬中,不论是⽤户头像上传,还是各种媒体⽂件的上传,以及各种个性的业务需求,都能得到满⾜。⼩⼩开⼼了⼀把。
但⽆论插件再怎么灵活,也难以应付所有的需求,⽐如,你要上传⼀个2G的⽂件。以现在我们的⽹速,恐怕再快也得传半⼩时。要命的是,如果你在上传到90%的时候不⼩⼼关掉了浏览器,或者是⼿⼀抖摁了F5,完了,⼀切还得从头再来。这种⽤户体验简直太糟糕了。所以,断点续传就⼗分有必要了。什么是续传我就不解释了,⽤QQ传⽂件这么多年,⼤家都见过了。
这⾥要说的是断点续传都有哪些技术要点。使⽤传统的表单提交⽂件或是HTML5的FormData都是将⽂件“整块”提交,服务端取到该⽂件后再进⾏转移、重命名等操作,因此,⽆法实时保存⽂件的已上传部分。⽽且在http协议下,我们⽆法保持浏览器与服务端的长连接,不能以⽂件流的形式来提交。所以要解决的问题具体来讲有以下⼏点:
对上传的⽂件进⾏分割,每次只上传⼀⼩⽚。服务端接收到⽂件后追加到原来部分,最后合并成完整的⽂件。
每次上传⽂件⽚前先获取已上传的⽂件⼤⼩,确定本次应切割的位置
每次上传完成后更新已上传⽂件⼤⼩的记录
标识客户端和服务端的⽂件,保证不会把A⽂件的内容追加到B⽂件上
在参考了张鑫旭⼤哥的后,我将学到的技术应⽤在了我的插件Huploadify中,成功的添加了断点续传功能。在此将技术和插件都分享给⼤家。
⼯作原理/技术要点
⾸先的⾸先,要明确,如果我们有⼀个10M的⽂件,每次切割上传1M,那么是需要发10次请求来完成的。在http协议下,只能这么搞。断点上传分三步来完成:
选择⼀个⽂件后,获取该⽂件在服务器上的⼤⼩,通过本地存储或⾃定义的函数来获取。
根据已上传⼤⼩切割⽂件,发出n次请求不断向服务器提交⽂件⽚,服务端不断追加⽂件内容
当已上传⽂件⼤⼩达到⽂件总⼤⼩时,上传结束
⾸先是⽂件的分割,HTML5新增了Blob数据类型,并且提供了⼀个可以分割数据的⽅法:slice(),其⽤法和字符串、数组的slice()⽅法⼀样,可以截取⼀个⼆进制⽂件的⼀部分。
其次是⽂件⽚的保存与追加,我后台⽤PHP写的,先⽤file_get_contents获取⽂件的⼆进制格式,再⽤file_put_contents每次将⽂件追加,具体的写法可以参照后⾯,或者是下载我打包好的⽂件。
接下来我们还需要实时保存已上传⽂件的⼤⼩,以便于下次上传前进⾏正确切割。使⽤HTML5的localStorage是⼀种⽅法,将已上传的⼤⼩保存在本地,下次上传前先从本地读取。不过这种⽅式是很局限的,抛开⽤户可能通过各种管家清除掉本地数据不讲,假如⽤户在A页⾯上传了⼀个⽂件的50%,然后在B页⾯想把该⽂件上传到另外⼀个地⽅,结果从本地⼀读⽂件已上传50%了,直接从51%的位置开始上传了,显然是个错误。问题就在于本地不能存太多的信息,通过File API只能获取到⽂件的原始名称,⽆法正确的与服务器上的⽂件正确匹配。所以真正在项⽬中⽤,还得依靠服务端来保存这些数据。
关于如何将数据存在服务端,已经前端如何取数据,我在下⾯会讲到。
技术要点就上⾯的那么多了,其实也没有多少技术含量哈~来看看我的插件如何使⽤吧。
续传功能的使⽤⽅法
⽂件的引⼊就不讲了,可参考上⼀篇关于插件的介绍。关键点是新增的⼏个配置,先来看⼀下:
breakPoints:false,//是否开启断点续传
fileSplitSize:1024*1024,//断点续传的⽂件块⼤⼩,单位Byte,默认1M
getUploadedSize:null,//类型:function,⾃定义获取已上传⽂件的⼤⼩函数,⽤于开启断点续传模式,可传⼊⼀个参数file,即当前上传的⽂件对象,需返回number类型saveUploadedSize:null,//类型:function,⾃定义保存已上传⽂件的⼤⼩函数,⽤于开启断点续传模式,可传⼊两个参数:file:当前上传的⽂件对象,value:已上传⽂件的⼤⼩,单位Byte
saveInfoLocal:false,//⽤于开启断点续传模式,是否使⽤localStorage存储已上传⽂件⼤⼩
这是插件中的默认配置值。⼀个续传功能竟然要配置五个项,真要命!不要着急听我慢慢道来,这五个并不是要同时出现的,是为了满⾜可能出现的复杂业务⽽准备的。
breakPoints是开启断点续传的开关,要使⽤的话设为true,默认是不开启的。
fileSplitSize是每次切割的⽂件⽚的⼤⼩,默认是1M,可根据实际情况来定。如果你的系统上传的⽂件普遍都在1G以上,可以配置的⼤⼀点。
getUploadedSize是⽤来⾃定义获取已上传的⽂件⼤⼩的函数,还记得上⾯说过的localStorage的局限吧,所以我这⾥直接把获取⽂件⼤⼩的函数交给你来定义,你可以从session、cookie,从⽂件、数据库或者任何地⽅取,可以发送⼀个ajax请求到你想要的地址,传递你需要的参数。注意你定义的函数将来会被插件调⽤,所以⼀定要返回⼀个Number类型的结果。  saveUploadedSize与getUploadedSize对应,你⾃⼰定义如何保存已上传⽂件的⼤⼩,只要你存的数据你⾃⼰能取到就OK。当然前提是你要注意到上⾯说过的localStorage的局限,确保你的逻辑正确能够操作到正确的⽂件。
saveInfoLocal是当你使⽤localStorage保存数据时需要开启的⼀个开关。插件默认提供使⽤localStorage⽅式的⽀持。只要开启此选项就可以了。当然,这种情况下你的业务逻辑必须⾜够简单,⽐如只是做⼀个上传的demo,或者这系统的⽤户只有你⼀个⼈,你明⽩如何避开那些局限的地⽅。
掌握了这五个配置的作⽤,你就可以实现⼀个⾜够灵活的断点上传功能了!在我打包好的⽂件⾥,提供了使⽤localStorage⽅式的demo,抱歉我⽆法将数据库表都发给你,所以只能⽤本地存储来演⽰。
在服务端保存数据
⽤户在使⽤上传的时候可能有各种你意想不到的操作,这⾥我发挥想象描述⼀下⽤户可能的⾏为:
同⼀台机器使⽤不同帐号登录,上传同⼀个⽂件
⽂件上传了⼀部分,然后修改了⽂件内容,再次上传
⽂件上传完成100%,再次上传该⽂件
同⼀个页⾯有多个上传按钮,上传同⼀个⽂件,或在不同页⾯上传同⼀个⽂件
仅仅上⾯四条,是不是情况就够复杂了?再加上你系统还有⾃⼰的业务逻辑,所以在服务端保存已上传⽂件数据是⾮常有必要的。⽽且保存数据和获取数据的函数都交给你来定义,抱着插件有⾜够的灵活性。
因为涉及到了服务端的技术,⽆法演⽰,我将我项⽬中的真实使⽤场景在此讲解⼀下,来展⽰⼀下如何⾃已定义⽅法来实现服务端保存数据的可靠上传。我定义的getUploadedSize函数如下:
getUploadedSize:function(file){
var data = {
data : {
fileName : file.name,
lastModifiedDate : Time()
}
};
var url = 'localhost/uploadfile/';
var uploadedSize = 0;
$.ajax({
url : url,
data : data,大文件发送
async : false,
type : 'POST',
success : function(returnData){
returnData = JSON.parse(returnData);
uploadedSize = returnData.uploadedSize;
}
});
return uploadedSize;
}
我向后台的某个地址发送⼀个请求,传递⽂件名和⽂件的最后修改时间为参数,后台根据这两个参数来到与前台所选择的⽂件对应的服务器上的⽂件,将服务器返回的⽂件⼤⼩return 出去,来被插件使⽤。为什么要传递这两个参数呢?我们在前台⽆法知道服务器上的这个⽂件的名称,所以使⽤原始⽂件名作为⼀个辅助标识。为了防⽌⽤户在两次上传间隔修改了⽂件,我们把⽂件的最后修改时间也传给服务端,让服务端进⾏⽐较,若时间不对应则返回已上传⼤⼩为0,重新上传此⽂件。
再来看后台都要做哪些⼯作。数据库中需要有⼀张表来记录每个已⽂件的情况,包含的字段⼤致有:
字段描述
client_filename⽂件在客户端的原始名称
server_filename⽂件在服务器上重命名后的名称
last_modified_date⽂件的最后修改时间,时间戳
status⽂件的状态,已完成、未完成
uploaded_size已上传⽂件的⼤⼩
根据client_filename和last_modified_date,再加上系统中的其他关联信息,可以定位到本次上传的⽂件在服务端的⼤⼩,然后返回给客户端。当然这是我⾃⼰的⽤法,你也可以根据⾃⼰的需求灵活设计。总之最终的⽬的就是要到前台选择的⽂件在服务器上真正对应的⽂件,并将已上传⼤⼩正确返回。
另外需注意的⼀点,就是在续传的第⼆步,不断提交⽂件⽚的过程中,也需要服务端准确定位到相应
的⽂件,不能把A的数据追加到B上。采⽤的⽅式也是提交fileName和lastModifyDate 两个参数(已写在插件内部,可服务端直接获取),服务端到对应的⽂件进⾏追加。
另外再啰嗦⼀句,后台获取⽂件的时候需要取成⼆进制的,⽽我们提交是使⽤FormData来提交的,所以PHP代码需要这么写:
file_put_contents('uploads/'.$filename,file_get_contents($_FILES["file"]["tmp_name"]),FILE_APPEND);
如果上⾯的说明还是不够清楚,就需要你⾃⼰来探索⼀下了,毕竟考虑到插件可能应⽤在复杂的系统中,很多⼯作还是需要你来做的。或者你也可以给我留⾔,我很乐意为你解答疑惑。该版本的其他改动
从1.0到2.0,Huploadify⼜新加了很多东西,不过只是新加,使⽤⽅式跟之前的没有变化。例如上⾯的断点续传功能,你如果不想使⽤,只需设置breakPoints为false即可,插件仍按照以前的⽅式⼯作。除了断点续传这个⼤头,插件还做了如下改动:
增加了onSelect回调函数,在选择了⽂件之后触发,⽤法与uploadify官⽹的⼀致
删除掉正在上传的⽂件,中断发送请求
完善了input file组件的accept属性⽀持,浏览时只显⽰运⾏的⽂件格式,就是这个东东:
4. 对外开放了⽅法调⽤接⼝,upload、stop、cancel、disable、ennable。我在demo中有演⽰。使⽤⽅法如下:
var up = $('#upload').Huploadify({
auto:false,
fileTypeExts:'*.jpg;*.png;*.exe;*.mp3;*.mp4;*.zip;*.doc;*.docx;*.ppt;*.pptx;*.xls;*.xlsx;*.pdf',
multi:true
});
up.upload(1);//开始上传⽂件,接收⼀个参数,表⽰上传第⼏个⽂件,可传⼊*上传队列中的所有⽂件
up.stop();//暂停上传队列中的所有⽂件,不接收参数。⽤于开启了断点需传
up.cancel(1);//删除队列中的某个⽂件,接收⼀个参数,表⽰删除第⼏个⽂件,可传⼊*删除队列中的所有⽂件
up.disable();//使选择⽂件按钮失效,不接收参数
结束
我在demo中使⽤了本地存储来做已上传⽂件⼤⼩的保存,下载压缩包后可看⼀下效果。上传⼀个⽐较⼤的视频⽂件,上传到中间关闭浏览器,再次打开浏览器上传同⼀个⽂件,会看到从上次断掉的地⽅继续上传。
详细内容可以参考我写的这篇⽂章: