本文实现的断点续传只是我对断点续传的一个理解。其中有不少不完善的地方,仅仅是记录了一个我对断点续传一个实现过程。你们应该也会发现我用的都是一些H5的api,老得浏览器不会支持,以及我并未将跨域考虑入内,还有一些可能出现的一场等~巴啦啦。(怎么感受这么多问题???笑~)html
本文参考仓库:点我前端
这几天在认认真真地学习KOA框架,了解它的原理以及KOA中间件的实现方法。在研究KOA如何处理上传的表单数据的时候,我灵光一闪,这是否是能够用于断点续传?node
断点续传并非服务器端一端的自high,他还须要前端的配合,并且我只准备扒拉一个大体的雏形,因此这个功能我准备:git
断点续传的过程不复杂,可是仍是有许多小知识点须要get,否则很难理解断点续传的工做过程。实现断点续传的方式有不少,不过我只研究了ajax的方式,因此预备的小知识点以下:github
content-type
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE1FeIoZcbW92IXSd
复制代码
HTML的form组件一共提供三种方式的编码方法:application/x-www-form-urlencoded
(默认)、multipart/form-data
、text/plain
。前两种方式比较常见,最后一种不太用,也不推荐使用。前两种的区别就是默认的方法是没法上传<input type="file"/>
的。因此若是咱们须要上传文件,那么就必定要用multipart/form-data
。web
raw data
在KOA中,server获取到的data都是raw data
也就是未经处理的二进制数据。咱们须要格式化这些数据,提取有效内容。咱们来分析一下如何处理这些raw data
。ajax
当咱们上传的时候,咱们会发现一个现象,就是content-type
还跟了一个小尾巴multipart/form-data; boundary=----WebKitFormBoundarygNnYG0jyz7vh9bjm
,这个长串的字符串是用来干吗的呢?看一眼完整的raw data
:后端
------WebKitFormBoundarygNnYG0jyz7vh9bjm Content-Disposition: form-data; name="size" 668 ------WebKitFormBoundarygNnYG0jyz7vh9bjm Content-Disposition: form-data; name="file"; filename="checked.png" Content-Type: image/png ------WebKitFormBoundarygNnYG0jyz7vh9bjm-- 复制代码
你们发现没每一个字段之间都有------WebKitFormBoundarygNnYG0jyz7vh9bjm
将他们分割开来。因此这里的boundary
是用来分割字段的。api
关于boundary
跨域
raw data
中,须要在前方加上--
,也就是这样--boundary
,若是是结尾的分隔符那么在末尾也加一个--
,就是这样--boundary--
更多详情,请参考The Multipart Content-Type
request
的data
和end
监听事件传数据给server,他也要有办法接受对不?因此这个时候,咱们须要配置data
监听数据的接受,以及end
监听数据的接受完毕。
每次data
事件触发,获取的数据都是一个Buffer类型的数据,而后将获取到的数据加到buf
数组中,等结束的时候,再用Buffer.concat
串联这些Buffer数据,变成一个完整的Buffer。就是这样,服务器将客户端的数据接受完毕了。
这一段就很简单了,ctx.req
是KOA中封装的request
。
let buf = []; let allData; ctx.req.on("data",(data)=>{ buf.push(data) }); ctx.req.on("end",(data)=>{ allData=Buffer.concat(buf) }) 复制代码
重点部分来了,这一部分了坑得我好惨。
咱们server获取到的raw data
不是字符串,而是一串Buffer
。Buffer是什么呢?是二进制数据。虽然咱们能够将Buffer
转为字符串再进行处理,可是遇到编码问题就会很头疼,由于toString
默认是utf-8
得编码格式。若是赶上不是utf-8
的,那么咱们获得的结果就颇有问题。因此说若是想要加工Buffer
数据就仍是要用Buffer
数据。好比------WebKitFormBoundarygNnYG0jyz7vh9bjm
这一段我想知道再Buffer中这个一段的位置。那么我么能够把这一段变成Buffer,而后去逐个查询。
来一段我和raw data的血泪沟通史(P一下哈哈):
raw data | 我 |
---|---|
我是一段二进制流 | 我要处理你 |
我要把你变成我最爱的string,人类可读的语言,而后再分割你 | |
若是我原本是人类可读,那么你能够这么作,万一我是图片或者其余格式,emmm | 会有什么问题吗 |
那么你就看不到我原来的样子了 | ??? |
简而言之,若是我是图片,你把我转成文字,写入文件的话,我就是一堆乱码 | what???(Φ皿Φ) |
因此你只能用个人同类来处理我 | 同类? |
也就是二进制流 | 也就是说我要把分隔符变成二进制流,而后来分割你? |
就是这样~ | 大哥我输了 |
虽然说我是二进制流,不过你能够用一个熟悉的方法来查询我 | 咦?有捷径吗? |
buf.indexOf(value) 能够帮助你查询位置 |
哦 |
buf.slice([start[, end]])能够帮助你无损分割我 | 哦 |
我只能帮你到这儿了 | 走好,不送 |
实现代码:
function splitBuffer(buffer,sep) { let arr = []; let pos = 0;//当前位置 let sepPosIndex = -1;//分隔符的位置 let sepPoslen = Buffer.from(sep).length;//分隔符的长度,以便肯定下一个开始的位置 do{ sepPosIndex=buffer.indexOf(sep,pos) if(sepPosIndex==-1){ //当sepPosIndex是-1的时候,表明已经到末尾了,那么直接直接一口读完最后的buffer arr.push(buffer.slice(pos)); }else{ arr.push(buffer.slice(pos,sepPosIndex)); } pos = sepPosIndex+sepPoslen }while(-1!==sepPosIndex) return arr } 复制代码
slice
方法slice
以前是用于数组的一个方法,如今文件也能够用slice
来分割拉,不过须要注意的是这个方法是一个新的api,也就是不少old的浏览器没法使用。
用法很简单:
//初始位置,长度
//这里的File对象是一个Blob,一个相似于二进制的流,因此这里是以字节为单位的。
File.slice(startByte, length);
复制代码
XMLHttpRequest
新建一个XMLHttpRequest
xhr = new XMLHttpRequest();
复制代码
打开一个post为请求的连接
xhr.open("post", "/submit", true); 复制代码
配置onreadystatechange
,捕获请求连接的状态。
xhr.onreadystatechange = function(){ //xhr.readyState //处理完成的逻辑 }; 复制代码
readyState | 意义 |
---|---|
0 | 初始化 |
1 | 加载中 |
2 | 加载完成 |
3 | 部分可用 |
4 | 加载完成 |
准备工做都作好了,最后send一下,请求连接。
xhr.send(表单数据);
复制代码
下面一节会写如何生成send中的表单数据
FormData
FormData
的使用很友好,就是按照健值一个个配对就能够了。
var formData = new FormData(); formData.append("test", "I am FormData"); formData.append("file", 你选择的文件); 复制代码
虽然简单,可是却能够模拟post的数据格式send给服务器。
写了这么多有关以后开发断点续传的相关知识点,咱们能够动手开始写了。断点续传的逻辑并不复杂大概就是这样的:
客户端client | 服务器端server |
---|---|
我想上传一个文件 | ok,no problem,不过你只能用post传给我 |
个人文件很大直接form 提交能够吗 |
有多大,若是很大的话,一旦咱们的链接断开,咱们就前功尽弃了啊!慎重啊! |
well,well,我把个人文件slice 成一小块一小块慢慢给你行了吧 |
来吧baby~,我不介意你多来几回 |
第一部分send |
接受中... |
等待中... | 接受完毕,处理接受的Blob,处理完毕已写入,你能够传第二部分了~ |
第二部分send |
接受中... |
等待中... | 接受完毕,处理接受的Blob,处理完毕已写入,你能够传第三部分了~ |
... | ... |
... | 终于结束了,我去处理下你的文件 |
... | ok~传送成功 |
从上述逻辑来看,这个前端的流程能够分为:
断点续传是客户端主动发送,服务器端被动接受的一个过程,因此这里是在客户端进行一个文件的切分,把文件根据range
的大小进行切分,range
的大小能够自定义。这里我为了防止每次上传切片都要计算位置,因此提早把全部的位置都放入了currentSlice
的数组之中。而后按顺序取位置。注意:这边切分所有是以字节为单位的计算。
createSlices(){ let s=0,e=-1,range=1024; for(let i = 0;i<Math.ceil(this.file.size/range);i++){ s=i*range,e=e+range e=e>this.file.size-1?this.file.size-1:e; this.currentSlice.push([s,e]) } } 复制代码
既然咱们知道了切分的碎片有多少片,那么按照已上传的碎片除以总碎片就能够获得进度啦,就顺手算个进度吧。这边感受好像很复杂的样子,淡定~我只是把界面样式都加进去了~
updateProcess(){ let process=Math.round(this.currentIndex/this.currentSlice.length*100) this.fileProcess.innerHTML=`<span class="process"><span style='width:${process}%'></span><b>${process}%</b></span><span>${this.fileSize}</span>` }, 复制代码
此外还需注意,文件的单位是字节,这个对于用户来讲很是不友好,为了告诉用户文件有多大,咱们须要转换一下。这里我是动态的转换,并非固定一个单位,由于若是一个文件只有几KB,而后我却用G的单位来计算,那么就是满眼的0了。这里能够根据文件大的大小,具体状况具体分析。我这里只给了一个KB和MB的计算。能够自行elseif加条件。
calculateSize(){ let fileSize=this.fileSize/1024; if(fileSize<512){ this.fileSize=Math.round(fileSize)+"KB" } else { this.fileSize=Math.round(fileSize/1024)+"MB" } }, 复制代码
既然要上传了,那就不得不召唤XMLHttpRequest
了。进行AJAX上传文件。上传文件必需要enctype="multipart/form-data"
,所以还须要请出FormData
帮咱们建立form表单数据。
先建立一个表单数据吧~,其实咱们只须要上传一个file的blob文件就能够了,可是服务器没有这么机智,可以自行给文件加独一无二的标识,因此咱们在传文件的时候要加上文件的信息,好比文件名,文件大小,还有文件切分的位置。这个部分就是随意发挥了,看你须要啥就加入啥子段,好比时间啦,用户id啦,巴啦啦~
createFormData(){ let formData = new FormData(); let start=this.currentSlice[this.currentIndex][0] let end=this.currentSlice[this.currentIndex][1] let fileData=this.file.slice(start,end) formData.append("start", start); formData.append("end", end); formData.append("size", this.file.size); formData.append("fileOriName", this.file.name); formData.append("file", fileData); return formData; } 复制代码
终于准备活动作完了,该上传了。这边就是一个标准的XMLHttpRequest
的上传模版,有么有很亲切很友好。这边不触及到跨域等那个啥的问题,因此很友好。你们只需在上传成功以后再回调此上传方法。逐个上传。直至最后一个切分。这里为了看出上传的过程,因此我加了一个500ms的延迟,这个仅仅是为了视觉效果,毕竟我只是试了几MB的文件,上传太快了。
createUpload(){ let _=this let formData=this.createFormData() let xhr = new XMLHttpRequest(); xhr.open("post", "/submit", true); xhr.onreadystatechange = function(){ if (xhr.readyState == 4&&parseInt(xhr.status)==200){ _.currentIndex++; if(_.currentIndex<=_.currentSlice.length-1){ setTimeout(()=>{ _.createUpload() },500) }else{ //完成后的处理 } _.updateProcess() } }; xhr.send(formData); } 复制代码
从上述逻辑来看,这个后端的流程能够分为:
这估计是整个流程中最简单的部分了,node监听一下,组装一下,搞定!
let buf=[] ctx.req.on("data",(data)=>{ buf.push(data) }); ctx.req.on("end",(data)=>{ if(buf.length>0){ string=Buffer.concat(buf) } }) 复制代码
你们还记不记得咱们传的是二进制,并且这个二进制除了文本字段,还有文件的二进制。这个时候,咱们就须要先提取字段,再将文件和普通文本分开处理。
先拼装分隔符,这边是一个规定,就是content-type
中的boundary
前面须要加上--
。
boundary=ctx.headers["content-type"].split("=")[1] boundary = '--'+boundary 复制代码
上文提到过二进制的分割只能用二进制,所以,我么能够把分隔符变成二进制,而后再分割接收到的内容。
function splitBuffer(buffer,sep) { let arr = []; let pos = 0;//当前位置 let sepPosIndex = -1;//分隔符的位置 let sepPoslen = Buffer.from(sep).length;//分隔符的长度,以便肯定下一个开始的位置 do{ sepPosIndex=buffer.indexOf(sep,pos) if(sepPosIndex==-1){ //当sepPosIndex是-1的时候,表明已经到末尾了,那么直接直接一口读完最后的buffer arr.push(buffer.slice(pos)); }else{ arr.push(buffer.slice(pos,sepPosIndex)); } pos = sepPosIndex+sepPoslen }while(-1!==sepPosIndex) return arr } 复制代码
分割完毕以后~就要开始处理啦!把字段都提取出来。这边咱们把提取出的内容变成字符串,首先这个是为了判断字段类型,其次若是不是文件,那么能够提取出咱们的字段文本,若是是文件类型的,那么就不能任性地toString
了,咱们须要把二进制的文件内容完美保存下来。
------WebKitFormBoundaryl8ZHdPtwG2eePQ2F Content-Disposition: form-data; name="file"; filename="blob" Content-Type: application/octet-streamk 换行*2 乱码 换行*1 ------WebKitFormBoundaryl8ZHdPtwG2eePQ2F-- 复制代码
上传的内容大概长这样,空行的代码是\r\n
,转化成二进制就是占2个位置,因此两个空行的截取就能够获取到字段信息和内容。由于末尾也有一个空行,因此在截取二进制文件内容的时候,除了头部的长度+2换行的长度,末尾的1换行长度也要加上,因此是line.slice(head.length + 4, -2)
这个样子的。
function copeData(buffer,boundary){ let lines = splitBuffer(buffer,boundary); lines=lines.slice(1,-1);//去除首尾 let obj={}; lines.forEach(line=>{ let [head,tail] = splitBuffer(line,"\r\n\r\n"); head = head.toString(); if(head.includes('filename')){ // 这是文件 obj["file"]= line.slice(head.length + 4, -2) }else{ // 文本 let name = head.match(/name="(\w*)"/)[1]; let value= tail.toString().slice(0,-2); obj[name]=value } }); } 复制代码
咱们上传的文件通常不存在原名保存,万一你们喜欢传重名的文件呢?头疼啊!这个时候就须要重命名,我通常喜欢用md5来计算新的文件名。这里能够拼接咱们上传的一些字段 好比时间,主要是给一个特殊的标识,以保证当前上传的文件区别去其余文件。毕竟相同的内容用md5计算都是同样的,相同的文件名md5计算后并无起到区分的做用。
固然文件的后缀不能忘记!否则文件保存下来了也打不开。因此记得提取一下文件后缀。
let fileOriName=crypto.createHash("md5").update(obj.fileOriName).digest("hex") let fileSuffix=obj.fileOriName.substring(obj.fileOriName.lastIndexOf(".")+1) 复制代码
此处我是按照是不是第一切片为主,看看是新建覆盖仍是从新追加文件内容。你们注意下,由于若是文件不存在直接appendFileSync
是会报错的。可是重复writeFileSync
又会覆盖内容。因此须要区分一下,你们能够经过判断文件是否存在来进行区分~。
if(parseInt(obj.start)===0){ fs.writeFileSync(__dirname+`/uploads/${fileOriName}.${fileSuffix}`,obj.file); }else{ fs.appendFileSync(__dirname+`/uploads/${fileOriName}.${fileSuffix}`,obj.file); } 复制代码
重复重复~直至客户端的切片所有传送完毕~
附录:
不理解KOA的能够看看我其余的文章:
本文的基础,参考KOA,5步手写一款粗糙的web框架
有关Router的实现思路,这份Koa的简易Router手敲指南请收下
有关模板实现思路,KOA的简易模板引擎实现方式