谈一谈大文件上传——前台分片和后台合并

欢迎光临我的博客拓跋的前端客栈,这个是原文地址。如果您发现我文章中存在错误,请尽情向我吐槽,大家一起学习一起进步φ(>ω<*)

最近做了一个需求,需要上传镜像的tar包,小的3、5G,大的可能会达到20多G,而要求在浏览器中上传,因此普通的上传方式肯定无法满足需求,必须要使用到分片上传,前台分片后台就需要合并,一系列做完以后踩了很多坑,在这边总结记录一下。

前台分片上传


所谓上传,实际上就是一个把文件通过客户端传给服务端的过程,也就是通过前端传给后台的过程。在这个过程中,如果要传输的内容太过庞大,在传输过程中就容易遇到各种各样的问题。为了把出现问题的概率控制在最低,我们往往需要把大文件分成一份一份的小文件来依次上传,这个过程就是我们所说分片上传。

我前台使用的是webuploader上传插件,参考webuploader的切片方法:

    function CuteFile( file, chunkSize ) {
        var pending = [],
            blob = file.source,
            total = blob.size,
            chunks = chunkSize ? Math.ceil( total / chunkSize ) : 1,
            start = 0,
            index = 0,
            len, api;

        api = {
            file: file,

            has: function() {
                return !!pending.length;
            },

            shift: function() {
                return pending.shift();
            },

            unshift: function( block ) {
                pending.unshift( block );
            }
        };

        while ( index < chunks ) {
            len = Math.min( chunkSize, total - start );

            pending.push({
                file: file,
                start: start,
                end: chunkSize ? (start + len) : total,
                total: total,
                chunks: chunks,
                chunk: index++,
                cuted: api
            });
            start += len;
        }

        file.blocks = pending.concat();
        file.remaning = pending.length;

        return api;
    }

其中,chunkSize表示每片大小,chunks表示切片的数目,具体切分方法也很简单,看代码就好了。

具体在使用webuploader的过程中,只需要在创建webuploader实例的过程中如下配置一下:

    const uploader = WebUploader.create({
        // 文件接收服务端。
        server: './ftp/images',

        // 选择文件的按钮。可选。
        // 内部根据当前运行是创建,可能是input元素,也可能是flash.
        pick: '#picker',

        chunked: true,//开启分片上传
        chunkSize: 50*1024*1024,//每片大小50M
        chunkRetry: 1,//失败后重试次数
        threads: 1,//上传并发数目
        fileNumLimit: 1,//验证文件总数量,超出则不允许加入队列

    });

后台合并


对于分片上传上来的文件,后台肯定要再次合并起来,重新生成跟原文件一模一样的文件。后台合并的方法有很多,以Node.js为例,可以使用以下方式:

buffer合并

按照惯例,先贴代码:

    // 合并分片
    function mergeChunks(fileName, chunks, callback) {
        console.log('chunks:' + chunks);
        let chunkPaths = chunks.map(function (name) {
            return path.join(process.env.IMAGESDIR, name)
        });

        // 采用Buffer方式合并
        const readStream = function (chunkArray, cb) {
            let buffers = [];
            chunkPaths.forEach(function (path) {
                let buffer = fs.readFileSync(path);
                buffers.push(buffer);
            });

            let concatBuffer = Buffer.concat(buffers);
            let concatFilePath = path.join(process.env.IMAGESDIR, fileName);
            fs.writeFileSync(concatFilePath, concatBuffer);

            chunkPaths.forEach(function (path) {
                fs.unlinkSync(path)
            })
            cb();
        };


        readStream(chunkPaths, callback);

    }

buffer方式合并是一种常见的文件合并方式,方法是将各个分片文件分别用fs.readFile()方式读取,然后通过Buffer.concat()进行合并。

这种方法简单易理解,但有个最大的缺点,就是你读取的文件有多大,合并的过程占用的内存就有多大,因为我们相当于把这个大文件的全部内容都一次性载入到内存中了,这是非常低效的。同时,Node默认的缓冲区大小的上限是2GB,一旦我们上传的大文件超出2GB,那使用这种方法就会失败。虽然可以通过修改缓冲区大小上限的方法来规避这个问题,但是鉴于这种合并方式极吃内存,我不建议您这么做。

那么,有更好的方式吗?那是当然,下面介绍一种stream合并方式。

stream合并

显然,stream(流,下面都用‘流’来表示stream)就是这种更好的方式。

按照惯例,先贴代码:

    // 合并分片
    function mergeChunks(fileName, chunks, callback) {
        console.log('chunks:' + chunks);
        let chunkPaths = chunks.map(function (name) {
            return path.join(process.env.IMAGESDIR, name)
        });

        // 采用Stream方式合并
        let targetStream = fs.createWriteStream(path.join(process.env.IMAGESDIR, fileName));
        const readStream = function (chunkArray, cb) {
            let path = chunkArray.shift();
            let originStream = fs.createReadStream(path);
            originStream.pipe(targetStream, {end: false});
            originStream.on("end", function () {
                // 删除文件
                fs.unlinkSync(path);
                if (chunkArray.length > 0) {
                    readStream(chunkArray, callback)
                } else {
                    cb()
                }
            });
        };

        readStream(chunkPaths, callback);

    }

为什么说流更好呢?流到底是什么呢?

流是数据的集合 —— 就像数组或字符串一样。区别在于流中的数据可能不会立刻就全部可用,并且你无需一次性的把这些数据全部放入内存。这使得流在操作大量数据或是数据从外部来源逐段发送过来的时候变得非常有用。

换句话说,当你使用buffer方式来处理一个2GB的文件,占用的内存可能是2GB以上,而当你使用流来处理这个文件,可能只会占用几十个M。这就是我们为什么选择流的原因所在。

在Node.js中,有4种基本类型的流,分别是可读流,可写流,双向流以及变换流。

  • 可读流是对一个可以读取数据的源的抽象。fs.createReadStream 方法是一个可读流的例子。
  • 可写流是对一个可以写入数据的目标的抽象。fs.createWriteStream 方法是一个可写流的例子。
  • 双向流既是可读的,又是可写的。TCP socket 就属于这种。
  • 变换流是一种特殊的双向流,它会基于写入的数据生成可供读取的数据。

所有的流都是EventEmitter的实例。它们发出可用于读取或写入数据的事件。然而,我们可以利用pipe方法以一种更简单的方式使用流中的数据。

在上面那段代码中,我们首先通过fs.createWriteStream()创建了一个可写流,用来存放最终合并的文件。然后使用fs.createReadStream()分别读取各个分片后的文件,再通过pipe()方式将读取的数据像倒水一样“倒”到可写流中,到监控到一杯水倒完后,马上接着倒下一杯,直到全部倒完为止。此时,全部文件合并完毕。

追加文件方式合并

追加文件方式合并指的是使用fs.appendFile()的方式来进行合并。fs.appendFile()的作用是异步地追加数据到一个文件,如果文件不存在则创建文件。data可以是一个字符串或buffer。例:

    fs.appendFile('message.txt', 'data to append', (err) => {
      if (err) throw err;
      console.log('The "data to append" was appended to file!');
    });

使用这种方法也可以将文件合并,虽然我没有在项目中实验,但通过在网上查的资料来看,性能强过buffer合并方式,但不及流合并方式。

小结


这篇文章虽然是谈前台分片和后台合并,但是由于前台分片主要是使用webUploader实现的,因此侧重点都在后台合并上。

后台合并主要有3种方式:buffer合并、流合并、追加文件方式合并。三种方式各有各的特点,但是在大文件合并上,我推荐使用流方式合并,流合并占内存最少,效率最高,是处理大文件的最佳选择。

推荐阅读更多精彩内容