天气渐渐炎热起来了,早早起来居然情不自禁的哼着抖音里一个粗狂汉子的羞答答的玫瑰静悄悄的开,难道是我的审美观变了,看着动作那么和谐,不禁多看了几遍……
我写的很多功能都是在自己日常开发中顺道独立出来的,通过博客的方式顺便再巩固和梳理下整体实现的流程(嗯……你好我好大家好,才是真的好),那这边主要是参考百度网盘web端的上传功能,实现核心的文件切片上传及断点续传功能。为了不耽误不需要的客官们的时间,我先贴个实际效果图,有图有真相,瞧得上眼的继续瞧,瞧不上眼的请继续尝试~~!
还是老规矩,在开发功能前,先要弄清楚实现的流程及原理,这样才能达到事半功倍的效果,那下面我们就进入流程中:
1.竞品分析
我们打开百度网盘的web端,先上传个文件,我们看看调试工具里的请求方式及上传时的操作过程
像百度网盘这种专业的文件管理系统,不用怀疑在上传大文件肯定会用到切片上传,上面可以看出它同时调用5次请求将切片依次上传,这里有点类似线程的意思,但在浏览器中是不支持多线程的,除了websocket,这里就是个伪多线程,同时for循环开启5个任务,执行完一个就新塞入一个。
跟踪上传进度使用了xhr对象(XMLHttpRequest),流程其实很简单,没有过多的复杂逻辑,其中遇到稍微难点的问题就是:
(1) 文件的切片是怎么实现的?
在前端直接Math函数将文件切割。
(2) 那这个暂停和继续又是怎么实现的?
这里通过观察百度网盘,其实它的暂停不是真正意义上的暂停,只是通过样式做了一个假象,但实际上传的过程还是在执行中。
(3) 断点续传怎么实现的?
当文件分片上传时,在服务端记录文件成功上传的分片id,通过MD5加密的文件信息作为索引,如果检测到是相同文件就返回上一次的分片点接着下载。
2.开发及功能实现
弄清楚上面的问题,就开发实现就变得简单多了,下面进行代码讲解,先看看前端核心代码:
//记录当前文件上传状态
var init={};
(function(){
//上传图片
$("#file_1").on("change", function() {
var file = $(this).get(0).files[0]; //文件对象
var fname = file.name; //文件名
var totalsize = file.size; //总大小
//换算M和Kb
if (totalsize > 1024 * 1024){
var tosize = (Math.round(totalsize * 100 / (1024 * 1024)) / 100).toString() + 'M';
}
else{
var tosize = (Math.round(totalsize * 100 / 1024) / 100).toString() + 'KB';
}
//生成随机字符串
var rndstr=randomString(16);
//加入当前上传全局管理状态
init[rndstr]=false;
//上传文件html
var html='<li class="info-con-li info-li-'+rndstr+'">\n' +
' <div class="process" style="width: 0%;"></div>\n' +
' <div class="info">\n' +
' <div class="file-name" title="'+fname+'">\n' +
' <div class="file-icon fileicon-small-zip"></div>\n' +
' <span class="name-text">'+fname+'</span>\n' +
' </div>\n' +
' <div class="file-size">'+tosize+'</div>\n' +
' <div class="file-status">\n' +
' <span class="waiting">排队中…</span>\n' +
' <span class="prepare">准备上传…</span>\n' +
' <span class="uploading" data-pernum="0" data-upsize="0" data-pretime="0" style="display: block">\n' +
' <em class="precent">0.00%</em>\n' +
' <em class="speed">(0kb/s)</em>\n' +
' </span>\n' +
' <span class="error"><em></em><i>服务器错误</i><b></b></span>\n' +
' <span class="pause"><em></em><i>已暂停</i></span>\n' +
' <span class="cancel"><em></em><i>已取消</i></span>\n' +
' <span class="success"><em></em><i></i></span>\n' +
' </div>\n' +
' <div class="file-operate">\n' +
' <em class="operate-pause"></em>\n' +
' <em class="operate-continue"></em>\n' +
' <em class="operate-retry"></em>\n' +
' <em class="operate-remove"></em>\n' +
' </div>\n' +
' </div>\n' +
' </li>';
$(".info-con-ul").append(html);
//暂停上传
$(".info-li-"+rndstr).find(".operate-pause").unbind('click').click(function(){
console.log("暂停上传");
//xhr.abort();
$(this).hide();
$(this).parent().parent().find(".uploading").hide();
$(this).parent().parent().find(".pause").show();
$(this).parent().find(".operate-continue").show();
});
//继续上传
$(".info-li-"+rndstr).find(".operate-continue").unbind('click').click(function(){
console.log("继续上传")
$(this).hide();
$(this).parent().parent().find(".uploading").show();
$(this).parent().parent().find(".pause").hide();
$(this).parent().find(".operate-pause").show();
console.log(init[rndstr])
if (!init[rndstr]){
$.ajax({
url: '/getrecord',
type: "POST",
data: {
"rndstr":rndstr
},
success:function(data){
console.log(data)
if (data.code==200){
fileUpload(file,data.zoneid,rndstr);
}else{
console.log(data.msg)
}
}
});
}
});
var i=0;
var xhr='';
fileUpload(file,i,rndstr,xhr);
});
//上传文件方法
function fileUpload(file,i,rndstr,xhr){
//修改当前上传状态
init[rndstr]=true;
$(".info-li-"+rndstr).find(".uploading").data("upsize",0);
var fname = file.name; //文件名
var totalsize = file.size; //总大小
var filesize = 0.5 * 1024 * 1024; //以500k为一个分片
var filenum = Math.ceil(totalsize / filesize); //总片数
//for(var i = 0;i < filenum;++i) {
//计算每一片的起始与结束位置
var start = i * filesize;
var end = Math.min(totalsize, start + filesize);
var curfile=file.slice(start,end);
var data = new FormData();
data.append('file', curfile);
data.append('filename', fname);
data.append('filenum', filenum);
data.append('rndstr', rndstr);
data.append('zoneid', i);
$.ajax({
url: '/upload',
type: "POST",
processData: false,
contentType: false,
data: data,
success:function(){
init[rndstr]=false;
//结合CSS处理xhr对象
if ($(".info-li-"+rndstr).find(".operate-continue").css('display') == 'block') {
xhr.abort();
}else{
i += 1;
if (i < filenum) {
fileUpload(file, i, rndstr,xhr);
}else{
$(".info-li-"+rndstr).find(".file-status span").hide();
$(".info-li-"+rndstr).find(".file-operate em").hide();
$(".info-li-"+rndstr).find(".success").show();
}
}
},
xhr: function(){
xhr = $.ajaxSettings.xhr();
if(uploadProgress && xhr.upload) {
xhr.upload.addEventListener("progress" , function(){uploadProgress(event,".info-li-"+rndstr,totalsize)}, false);
return xhr;
}
}
})
//}
}
//上传进度跟踪
function uploadProgress(evt,classname,totalsize){
//获取上一次的已上传文件大小及时间
var preupsize=parseFloat($(classname).find(".uploading").data("upsize"));
var pretime=parseFloat($(classname).find(".uploading").data("pretime"));
var loaded = evt.loaded; //已经上传大小情况
var per = (loaded-preupsize)/totalsize; //已经上传的百分比
var nowtime = new Date().getTime(); //获取当前时间
//通过当前和上一次的上传大小及时间计算上传速度
var speed =(loaded-preupsize)/1024/((nowtime-pretime)/1000);
var speedStr = (speed >= 1024) ? (speed/1024) + "":speed + "";
speedStr=toDecimal2(speedStr);
speedStr= speedStr+ (speed>1024?" Mb/s" : " Kb/s");
//赋值
$(classname).find(".speed").html('('+speedStr+')');
$(classname).find(".uploading").data("upsize",loaded);
$(classname).find(".uploading").data("pretime",nowtime);
//当前总的文件上传大小
var cursize=parseFloat($(classname).find(".uploading").data("pernum"));
cursize+=per;
cursize=cursize>1?1:cursize;
var curper=toDecimal2(cursize*100)+"%";
//暂停时进度条不执行,但流程还在继续
if ($(classname).find(".operate-continue").css('display') == 'none') {
$(classname).find(".process").css('width', curper);
}
$(classname).find(".uploading").data("pernum",cursize);
$(classname).find(".precent").html(curper);
}
//制保留2位小数,如:2,会在2后面补上00.即2.00
function toDecimal2(x) {
var f = parseFloat(x);
if (isNaN(f)) {
return false;
}
var f = Math.round(x*100)/100;
var s = f.toString();
var rs = s.indexOf('.');
if (rs < 0) {
rs = s.length;
s += '.';
}
while (s.length <= rs + 2) {
s += '0';
}
return s;
}
//生成随机字符串
function randomString(len) {
len = len || 32;
var $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var maxPos = $chars.length;
var pwd = '';
for (var i = 0; i < len; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd;
}
})()
这里我就简单的使用了jquery,创建一个匿名函数方法中执行,上面都有注释基本能看懂,不懂的可以给我留言,有些为了方面我就结合赋值给页面一直参数来保存数据,有兴趣的同学可以将一些局部变量在全局变量分配设置,页面的代码我就直接用了百度网盘的上传文件样式,如果需要完整的代码后面会提供。
服务端我还是使用python的Sanic框架,用python有许多好处,搭建框架特别快,方便快捷……,来废话不多说,看代码:
#上传文件接口
@app.route('/upload',methods=["POST"])
async def upload(request):
args = request.args if request.method == 'GET' else request.form
filename = args.get('filename', '') #文件名
filenum = args.get('filenum', 0) #文件分片总数
rndstr = args.get('rndstr', '') #随机字符串(唯一标识)
zoneid = args.get('zoneid', 0) #当前分片id
file = request.files.get('file') #文件流
path = os.path.abspath(os.path.dirname(__file__))
#基本文件夹路径
basepath="{}\\files\\{}".format(path,rndstr)
datapath="{}\\data".format(basepath)
if not os.path.exists(datapath):
os.makedirs(datapath)
#保存文件分片
with open("{}\\{}".format(datapath,zoneid), 'wb') as f:
f.write(file.body)
#记录当前完成的分片id
with open("{}\\record.txt".format(basepath), 'w',encoding="utf-8") as f:
txt="{}|{}".format(zoneid,rndstr)
f.write(txt)
#判断文件是否上传完毕
if int(filenum)==int(zoneid)+1:
u=Upload()
u.mergefiles(rndstr,filename,int(filenum))
msg={"code":200,"msg":"开始上传"}
return json(msg)
#获取文件上传保存的进度
@app.route('/getrecord',methods=["POST"])
async def getrecord(request):
args = request.args if request.method == 'GET' else request.form
rndstr = args.get('rndstr', '')
if rndstr!="":
path = os.path.abspath(os.path.dirname(__file__))
basepath = "{}\\files\\{}".format(path, rndstr)
if os.path.exists("{}\\record.txt".format(basepath)):
with open("{}\\record.txt".format(basepath), 'r') as f:
result=f.read()
print(result)
if result!="":
zoneid=int(result.split("|")[0])+1
msg = {"code": 200, "msg": "请求成功", "zoneid": zoneid}
else:
msg = {"code": 203, "msg": "正在创建文件中"}
else:
msg = {"code": 204, "msg": "请求异常"}
return json(msg)
这里我们随便写了下,有些不是很规范,后期可以自行优化下代码格式,还有个upload类:
#coding:utf-8
import os
class Upload():
def __init__(self):
pass
#合并文件
def mergefiles(self,rndstr,filename,filenum):
path = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
with open('{}\\files\\{}\\{}'.format(path,rndstr,filename), 'wb') as file: # 创建新文件
for i in range(filenum):
try:
filename = '{}\\files\\{}\\data\\{}'.format(path,rndstr,i)
source_file = open(filename, 'rb') # 按序打开每个分片
file.write(source_file.read()) # 读取分片内容写入新文件
source_file.close()
except IOError:
break
#删除已合并的分片
#os.remove(filename)
if __name__=='__main__':
u=Upload()
u.mergefiles("e4aMQGZZkYi7iJem","图标文件.zip",2)
通过上面的代码基本就实现了百度网盘上传文件的功能,由于时间关系,我这里只是基本实现逻辑功能,比如我这里只开了一个任务循环去调用等等,在细节的方面如果要达到真正的实用的话,还有些地方需要自己去优化,我们看下上传后的文件:
3.优化及改进
其实上面只是实现了基本的功能部分,在上传过程中还涉及到很多问题,比如接口用户的验证,实现多任务上传,文件信息的MD5加密,断点续传的完整实现等等,上面其实通过getrecord这个方法就可以实现断点续传功能,只是我在传递文件唯一值的时候不是传的文件的MD5加密信息,而是随机生成的字符串,如果页面刷新后就会重新下载,所以一般是用文件的名称,大小,上一次修改时间,类型等等进行MD5加密作为判断唯一性的依据,这个就留给客官们自己去操作了,还有就是关于百度网盘秒传的实现,这里我就提一嘴,其基本思路大致是根据文件的MD5信息去数据库匹配,如果匹配到成功曾经上传过的,就直接复制一份过来,以上仅代表我个人的观点,马克思说的好:实践才是真理。
好了,认真的时光总是过得这么快,让我们愉快的继续敲代码吧~~!
(在源代码中我增加了MD5验证及秒传功能)
白嫖的兄弟们如果需要完整代码的请关注公众号回复:文件上传
关注公众号,超越平凡才能成就自我