大文件断点续传

时间:2021-02-04 12:48:11

win10 node: v8.2.1 npm: v5.3.0 multer: v1.3.0

使用

1.由于对multer v1.3.0做了修改,所以不可以通过npm install multer这种形式,需要使用到修改过multer包去覆盖原来的。
2.对于文件上传的接口,比如/upload,需要携带参数targetFileName和start。

  • targetFileName: 服务端生成目标文件的名字。targetFileName可在seg-worker.js中导出。seg-worker是一个web worker。var w1 = new Worker('seg-worker.js'); w1.postMessage({file: file})
  • start: 写入到这个文件中的位置。

3.如果是分段上传,需要在multer.diskStorage中添加一个字段seg。

multer.diskStorage({
destination: cb,
filename: cb,
seg: true
})

同时需要修改源码。见下文update4

在multerv1.3.0版本中,通过multer.memoryStoragemulter.diskStorage來配置文件的destinationfilename。具体怎么写入的这些细节multer内部做了封装。下面是实现过程。完整流程图在最下方。

update1

同一个用户可能会重复上传看似相同实则不同的文件。比如两个文件,文件名一样、大小一样、相关时间都一样。但是内容不一样。这样服务器会判断出两个文件是一样的,禁止用户重复上传。

解决这个问题是在前端使用一个spark-md5的库。该库会根据文件内容计算出文件的md5。

update2

因为是大文件,要做分段上传,并且还可以续传。比如,对于一个2g的大文件,如果上传到中途因为断网需要从头开始上传,这是很麻烦的事情。断点续传可以解决这个问题。

基于multer,最开始的做法是在文件上传之前,在服务端建立一个和源文件大小相同的新文件(以文件md5命名)。因
为段上传是一个接一个,当multer写完某个段到磁盘后,该文件追加这个段。然后响应给客户端,客户端再上传下一个段。这样保证了顺序,但是牺牲了速度。至于如何续传,可以在上传前去服务端或者本地缓存拿到已经上传的进度。

update3

因为浏览器可以同时发起多个请求,所以上述的一个接一个的请求没有充分利用浏览器的这种特性。所以可以for循环,同时发送所有ajax请求(浏览器会限制数量)。那么如何保证上传文件的顺序是个问题。这需要用到spark-md5这个库。这需要计算出每个段的md5以及整个文件的md5。前者在拼接文件时用到(保证顺序),后者用来标志文件的独一性。

基于multer,最开始的做法是当multer写完某个段到磁盘后,判断是否所有的段都上传完毕。如果上传完毕了,就新建一个和源文件相同大小的文件,然后依次将这些段追加到该文件。因为每个段都有一个md5,并且服务端事先取到了所有的段的md5。依次遍历这些段的md5,就可以做到依次追加。追加完成后,删除该段。最后理想的结果是生成一个与源文件同样大小的文件,并且所有段被删除。

最原始版本的断点续传就这样完成了。

这样可能造成的问题有:

  • 最多时占用两倍服务端空间,这在用户量大的时候是非常可怕的
  • 写完一个段,然后再复制,再追加到新文件
  • multer对于此的处理有一个bug。当一个请求被取消(刷新页面,xhr.abort())的时候,可能该段已经上传到服务端一部分(几百k),然后再次上传,最终能得到完整的文件。但是这些之前上传了一部分的段没有删除掉。

update4

对于update3中的第三个问题,是因为上传的文件流fileStream,也叫做源流,会被添加到目的流。即src.pipe(dest)。当有文件上传的时候,就将数据写到目的流。这时候请求被取消,这个源流到目的流的关系并不会被取消,而是一直保持。只有当一个文件(段)上传完毕的时候,这种关系才会结束src.unpipe(dest)

解决这个问题,需要修改源码。当一个请求被取消的时候,会触发reqclose事件。

// multer/lib/make-middleware.js 96行左右
busboy.on('file', function (fieldname, fileStream, filename, encoding, mimetype) {
// when req is closed, like refresh page or xhr.abort(), remove the destination stream(busboy)
req.on('close', function() {
busboy.emit('finish')
})
// ......
}

其中,我就添加了上述三行代码req.on('close', cb)。busboy的finish事件中有一个操作req.unpipe(busboy)。也就是src.unpipe(dest)。也就是说,当请求被取消的时候,req将busboy从目的流中移除。busboy就不会再占用这个文件了。所以可以删除掉了(无论是程序删除还是手动删除)。

对于update3中的前两个问题,自然而然得想到了:先建立一个和源文件同等大小的文件,然后当段文件到来的时候,直接写到新建文件的对应位置。而不是直接写到磁盘。

但是multer没有提供这种操作,multer在将文件写入到指定文件夹后才暴露出文件相关的信息给用户。所以需要改源码。

后端操作如下:
1.当一个文件上传前,需要请求pre-uplaod接口。通过文件名+文件md5(也可加上用户id)的方式来判断该文件是否已经上传过。global.uploads是一个对象,存储着文件相关的信息(实际中这些信息在数据库)。如果文件没有上传,在global.uploads上添加一个key,值是该文件的相关信息。返回响应给客户端,客户端可以上传文件。如果文件上传了部分或上传过,返回不同响应给客户端。

2.文件上传到后端,会进入multer。multer原本的处理是:

finalPath = path.join(destination, filename)
outStream = fs.createWriteStream(finalPath)

更正的部分:

// multer/storage/disk.js 41行左右
if(that.seg) {
var targetFileName = req.query.targetFileName
var start = req.query.start
if(!targetFileName) throw "query parameter of targetFileName is required"
if(!start) throw "query parameter of start is required"

finalPath = path.join(destination, targetFileName)
outStream = fs.createWriteStream(finalPath, {
flags: 'r+',
autoClose: true,
start: parseInt(req.query.start)
})
} else {
finalPath = path.join(destination, filename)
outStream = fs.createWriteStream(finalPath)
}

在multer.diskStorage配置中添加了一个seg属性。

var storage = multer.diskStorage({
destination: cb,
filename: cb,
seg: true
});

如果是分段上传,就写到目标文件。因为是文件上传,前端用的是FormData。如果在FormData中添加数据,后端req.body无法拿到数据(可以借助其他包)。所以将数据放到了req.query中。targetFileName是必须的,表示要将这个段写入到那个文件。start也是必须的,表示要将这个段写入到目标文件的那个位置。所以前端需要/upload?targetFileName=xxx&start=xxx

update5

基本功能到这里就完成了。后面的是优化部分。使用web worker可以开辟另外一个线程,防止堵塞主线程。在两处使用了web worker。分别是给文件分段部分,该部分使用worker意义不大,因为FileReader读取文件的操作是异步的。并不会堵塞主线程。第二处是分段完毕后发送ajax请求部分,因为for循环发送所有请求。文件很大的情况下会造成堵塞。所以放到web worker。

update6

封装–为了更方便的调用。

update7

在multer2.0.x版本中,暴露出了file stream API,通过这个API可以自己决定将文件写到哪里,而不是修改源码。

流程图:https://www.processon.com/view/link/5a0ce9dee4b0d53d97995322
时序图:https://www.processon.com/view/link/5a0f8d30e4b049e7f4ff068b

由于项目在公司完成,所以源码不便透露。有问题可以联系我微信a127620310。