早先我们在做网站的大文件上传时一般都是借助flash控件的,那个时代html5没有开放文件操作接口给js,而直接用表单提交大文件的话,以那个时候的带宽来上传百兆文件要花非常久的时间,我们无法保证用户能耐心等到上传完(在此期间,用户不能刷新页面,只能等~)。一旦用户的网络出现抖动,就可能导致前功尽弃,这些是不可接受的!

那么做到什么样的程度才能够保证大文件上传的可用性呢?我在这里简单的列出来一些点:

  • 支持断点续传
  • 支持分块
  • 支持多线程(*)
  • 支持秒传(*)
  • 支持显示上传进度
  • 支持图片预览
  • 支持暂停上传
  • 拖拽上传

上面列出的这几点是我认为都需要实现的,带星号的我觉得算是加分项吧,毕竟像迅雷客户端这样的体验真的是太劲爆了。

接下来我们就根据上面列出的几点来分析,基于一款百度开源的前端上传控件:WebUploader(以下简称:WU)。

上传图片预览

这一点其实在html5开放了本地文件操作接口后,就变得非常简单了。WU已经把这个功能封装起来了,用起来非常的简单,这里就不多说了。

不仅如此,WU还提供了更强大的功能,它可以在生成缩略图的时候进行图片压缩处理,具体可从API文档中查看到。

上传进度

如果让用户等待十几分钟,不做任何提示肯定是非常不合适的,天知道是正在上传,还是死机了啊!这一点也是依靠HTML5的新特性做到的。不过在WU封装后,它就成了一个可监听的事件而已,这里也不多说了。

拖拽上传

你可能会觉得这个功能没啥大用,但我却把它视为革命性的飞跃。不过在WU里实现起来只需要靠一个配置两个参数即可:dnd和disableGlobalDnd

分块,暂停上传,多线程,断点续传

其实只要支持了分块功能,像什么断点续传啊多线程啊就都有了!而借助html5提供给我们的文件API,分块也灰常的简单。我们就直接拿WU来说吧,它的配置中有以下几个参数:

  • chunked
  • chunkSize
  • chunkRetry
  • threads
  • prepareNextFile

    var uploader = WebUploader.create({
            swf: "Uploader.swf"
            , server: "fileUpload.php"
            , pick: "#picker"
            , resize: false
            , dnd: "#theList"
            , paste: document.body
            , disableGlobalDnd: true
            , thumb: {
                width: 100
                , height: 100
                , quality: 70
                , allowMagnify: true
                , crop: true
            }
            , compress: false
            , prepareNextFile: true
            , chunked: true
            , chunkSize: 5000 * 1024
            , threads: true
            , formData: userInfo
            , fileNumLimit: 1
            , fileSingleSizeLimit: 1000 * 1024 * 1024
            , duplicate: true
        });
    

我把每个分片设置为5M大小,同时开启了多线程。这样设置以后,你就会发现上传被切分成多次请求了,如下图:

注意,每个分片的上传请求中都包含三个重要的标识信息:

  • size:表示文件的总大小
  • chunks:表示分片的总个数
  • chunk:表示当前是第几个分片(分片下标从0开始)

WU已经把该做的做完了,而且做得还很不错,那么它都做了什么:

  • 文件拆分成指定大小的块
  • 分块多线程发起上传请求
  • 提供暂停功能
  • 分块上传失败后重试

接下来就是我们后端代码要负责的了,我简单列一下后端要解决的问题点:

  1. 如何识别不同的分块是否属于同一个目标文件
  2. 何时把多分片合并成一个完整的文件
    • 如何知道所有的分块全部传完完毕
      • 如何避免合并大文件造成的内存占用
      • 长期未上传完成的文件如何清理
  3. 如何从指定分块开始上传(续传,暂停后继续)

第一个问题要如何解决呢?如果能拿到每个文件的唯一标识的话,我们就可以通过这个标识来甄别分块的归属,至于这个唯一标识要根据哪些数据生成,这就要看你的系统的具体情况了,我这里简单的利用上传文件的相关信息和当前用户的id信息做md5签名作为这个标识:

uniqueFileName = md5(''+userInfo.userId+file.name+file.type+file.lastModifiedDate+file.size);

其中userInfo来自于用户登录后返回给前端框架的用户信息,其中userId即为用户的id。file其实是WU提供的一个对象,用来表示特定的上传文件,我们是怎么拿到这个对象的呢? 这里需要简单的介绍一下WU提供的Hookbefore-send-file

文档中指明,它用来在文件发送之前触发,接受file为参数,这个对象就是我们要的!当然,其实也可以WU提供的其它相关事件的回调中获得这个对象,不过放在这里是为了更好的和后面讲的秒传配合。代码片段为:

var userInfo = {userId:"kazaff"};    //模拟用户信息
var uniqueFileName = null;        //为了避免每次分片都重新计算唯一标识,放在全局变量中,但如果你的项目需要WU支持多文件上传,那么这里你需要的是一个K/V结构,key可以存file.id

WebUploader.Uploader.register({
    "before-send-file": "beforeSendFile"
    ......
}, {
    beforeSendFile: function(file){
        .....
        //拿到上传文件的唯一名称,用于断点续传
        uniqueFileName = md5(''+userInfo.userId+file.id+file.name+file.type+file.lastModifiedDate+file.size);
        .....
    }
    ......
});

上面的代码片段我省略了很多不相干的代码,不过放心最后我会把这个例子的完整代码放到github上的。

这样我们就有了这个唯一标识文件的戳,不过这里我们是在前端生成的,前端的这个戳主要是用于断点续传的,因为每个分片上传前我们都会让WU先发送一个请求来确认该分片是否已经上传过,如果后端告诉我们已经已经传过了,那么WU就会直接跳过该分片,这就实现了我们的断点续传,其实暂停继续和断点续传没啥两样,我们后面再说。当然,判断一个分片是否已经上传,只通过这个文件戳肯定不够,看下图:

请求中还提供了:分片索引和该分片的大小,这两个数据我们是怎么获取到的呢?依然是利用WU提供的Hook:before-send。这个Hook可以在分片发送之前触发,提供给我们block对象参数就是分片的相关信息,其中包括了我们要的两个数据,代码如下:

.......
, beforeSend: function(block){
        //分片验证是否已传过,用于断点续传
        var task = new $.Deferred();
        $.ajax({
            type: "POST"
            , url: "fileUpload.php"
            , data: {
                type: "chunkCheck"
                , file: uniqueFileName
                , chunkIndex: block.chunk
                , size: block.end - block.start
            }
            , cache: false
            , timeout: 1000 //todo 超时的话,只能认为该分片未上传过
            , dataType: "json"
        }).then(function(data, textStatus, jqXHR){
            if(data.ifExist){   //若存在,返回失败给WebUploader,表明该分块不需要上传
                task.reject();
            }else{
                task.resolve();
            }
        }, function(jqXHR, textStatus, errorThrown){    //任何形式的验证失败,都触发重新上传
            task.resolve();
        });

        return $.when(task);
    }
.......

我在这个Hook中发起了一个ajax请求,用于让后端告诉我们该分片是否需要上传~~

细心的童鞋应该能注意到一个细节,这么设计也就会导致每个分片的上传可能要发送2次请求,一次用于分片验证,一次用于分片上传。这就需要我们设置一个合理的分片大小,用来避免一个大文件会向后端发起大量的请求,我的选择是5M~~

做到这里,可以说WU已经做好了暂停继续,断点续传,多线程的全部工作,它们都是基于分块的。再进入后端前,我们先来分析一下断点续传和暂停继续的差别:

断点续传表示用户可能刷新了页面,或者甚至干脆重启了电脑,反正总之浏览器丢失了正在上传的文件的所有相关数据:文件路径,正在上传的分片索引,上传进度等信息。

暂停继续表示用户仍然在当前页面,只是主动触发暂停上传的动作,并在一定时间间隔后再次触发继续上传行为,期间浏览器依然记录着关于上传文件的相关信息。

除了上面提到的差别外,就没有其它了。也就是说这些差别仅位于浏览器,也就是前端,也就是WU,跟后端关系不大,或者说对于后端处理来说,这两者并没有什么区别,难道不是么?后端只需要回答指定分片是否已存在的问题即可,才不会管这次验证请求来自于断点续传还是暂停继续!!

ok,现在来说后端吧,刚才我们在前端创建文件的唯一标识,后端要怎么应对呢?当前端的分片验证请求或分片上传请求过来后,后端需要确定这个请求所对应的文件是哪个,而且要把在整个上传过程中生成的分片关联起来,用于合并时查找使用。方案很多,我这里选择一个不依赖数据库的。

后端在接受到分片上传请求后,会根据我们在前端创建文件标识的方法在后端也创建一个一模一样的标识,用这个标识创建一个文件夹,并把分片上传至这个文件夹中,这样所有同一个文件的分片都会保存在相同的文件夹中,如下图所示:

上图来自于某个大文件上传的过程中,以2e2a311b96d816b695fdf5817bd030f8命名的文件夹下的分片。

到此为止,我们解决了第一个问题(也为后面的问题做了铺垫)。

第二个问题涉及到合并这些分片的时机和方式,这里要明确一点,WU分片上传时会以分片的先后顺序上传,但由于网络因素,不保证0号分片一定先于1号分片上传完成,所以后端不能根据分片下标来判断上传是否完毕

既然如此,我们只能在每个分片上传完毕后检查一下当前文件夹下的文件数是否与总分片数一致,以此来确定是否上传完毕,如果觉得这样做性能不满意,也可以借助WU的Hook:after-send-file,这样在所有分片上传完毕后,让WU在该Hook中发送一个ajax请求告诉后端可以合并了,并拿到合并后的目标文件相关信息(路径)。这里我选择了前者。

以上段落需更正,见文章末尾的[更正1]。

至于合并的方式,我们只需要避免大文件合并时的内存溢出问题即可,这一点跟后端实现语言相关,我打算分别以PHP,NodeJS,JAVA来实现。避免文件读写造成的内存溢出,常用的手段是管道(Pipe),后端代码也会在github上提供,在此就不展示了。

另外我们还要解决一件事儿:如果某个大文件上传了一半,出于种种理由用户决定再也不会上传它的其他部分了,服务器端怎么办?这些过期分片永远存在服务器端的硬盘上显然无法让正常人接受。我们要怎么办呢?方案同样很多,我这里使用的是为每个上传的文件(不是分片)创建一个临时文件,名字使用上面创建的那个文件唯一标识,如:2e2a311b96d816b695fdf5817bd030f8.tmp。这个文件的修改时间会在每次有分片请求时被更新,然后再开启一个定时任务,比方说每夜凌晨检查一下上传文件夹路径下的所有tmp文件的修改时间,如果发现最后的修改时间距当前时间已经很久了,则认为其对应的分片数据需要被清理。如下图:

好吧,关于第二个问题我们也算搞定了,最后一个问题:如何从指定分块开始上传?这个算是断点续传和暂停继续的核心问题了,而这一点我们需要依赖WU提供的机制:WU分片上传大文件时,会依次上传被切分的分片数据,在这个过程中所需要的分片下标,上传进度等数据都是由WU负责管理的,后端并不需要关注。

每次分片上传前,都会发起一次校验请求,我们就是通过这样的方式来解决第三个问题的。对于暂停继续,WU保存着分片下标的信息,所以它只会从暂停时的那个分片开始询问,而断点续传由于丢失了分片下标信息,则会从0号分片开始询问。这些内容上面已经提及了~~

秒传

相信大家肯定使用过各种类型的网盘,或者是迅雷会员,都会非常中意“离线下载”这个功能。谁都希望添加到迅雷里的下载任务能够一秒钟下载完毕,同样,我们的用户也希望能够一秒钟就上传完一个1G的文件。但这是不可能的,至少不是真的通过上传的方式实现的!

假设用户A要传的文件是在之前就被用户B上传过的(比方说一部岛国动作片儿,你懂的),这个时候我们不需要傻傻的把这个文件再上传一遍,只需要告诉用户A已经上传完毕了,并返回给他对应的链接,我相信用户A会非常满意的!当然,我们的系统还要处理“写时复制”问题(当用户B删除文件时,由于用户A还在引用,所以文件不应该删除,而应该切断用户B的链接,直到所有用户都不再引用该文件,此时才应该真实删除文件)!

好吧,我们来看看具体如何实现秒传:应该找个方法来判断上传的文件是否服务器端已经存在!这似乎和我们上面提到的文件唯一标识有点类似,但注意我们上面的那个标识中引入了用户id,所以肯定不符合我们这里的情况。那么去掉用户id是否就ok了呢?

恩,其实是可以的,不过这种方式生成的一致性验证其实并没有对内容本身进行验证,而是通过其文件的相关信息来做的判断,也就是说如果两个文件的文件名,大小等“凑巧”一模一样,但这两个文件内容不同的话,就会导致一致性校验错误。

电驴是怎么做的?它会用文件的二进制数据计算文件的md5签名,这样即使文件的名字大小都一样,内容不同也会导致它们的签名不同~这样基本上就是万无一失了,不过对一个大文件进行md5签名可能要话费非常多的时间,这就需要我们来取舍了。

我在自己的开发机测试了一下,用WU提供的md5File函数计算一个266M的文件的md5签名,耗时:15.573秒。

WU的md5File函数支持对文件的部分数据做md5签名,这样的话我们就可以对大文件中的一部分内容签名,避免太大的耗时。牛逼坏了!我们按照官方文档的提示使用Hook:before-send-file

...... 
beforeSendFile: function(file){
        //秒传验证
        var task = new $.Deferred();
        var start = new Date().getTime();
        (new WebUploader.Uploader()).md5File(file, 0, 10*1024*1024).progress(function(percentage){
            console.log(percentage);
        }).then(function(val){
            console.log("总耗时: "+((new Date().getTime()) - start)/1000);

            $.ajax({
                type: "POST"
                , url: "fileUpload.php"
                , data: {
                    type: "md5Check"
                    , md5: val
                }
                , cache: false
                , timeout: 1000 //todo 超时的话,只能认为该文件不曾上传过
                , dataType: "json"
            }).then(function(data, textStatus, jqXHR){
                if(data.ifExist){   //若存在,这返回失败给WebUploader,表明该文件不需要上传
                    task.reject();

                    uploader.skipFile(file);
                    file.path = data.path;
                    UploadComlate(file);
                }else{
                    task.resolve();
                    //拿到上传文件的唯一名称,用于断点续传
                    uniqueFileName = md5(''+userInfo.userId+file.name+file.type+file.lastModifiedDate+file.size);
                }
            }, function(jqXHR, textStatus, errorThrown){    //任何形式的验证失败,都触发重新上传
                task.resolve();
                //拿到上传文件的唯一名称,用于断点续传
                uniqueFileName = md5(''+userInfo.userId+file.name+file.type+file.lastModifiedDate+file.size);
            });
        });
        return $.when(task);
    }
    ......

好啦,前端做完了,后端只需要拿到对应的md5签名,然后进行相关的检索,如果确实存在这个签名匹配的文件,则提示前端该文件已经存在。这部分通常会利用数据库来完成,当然也可以使用内存KV数据结构来做,我们不纠结这!


好啦,截止到这里,我们大概已经完成了大文件的上传问题,完整的代码我会稍后在github上提供出来,有兴趣的童鞋可以去瞅瞅。


更正1:

尽管我们在例子中已经设置为单文件上传,并且在生成文件的唯一标识时加上了userId,但这并不能阻碍并发上传时造成的冲突,先来看下面这个场景,让我来手绘一张图,真的是纯手绘哟~~:

假设我们的用户A用浏览器打开了两个tab窗口(userId一致):tab1,tab2。这两个窗口同时上传同一个文件(文件标识一致),这里暂不考虑浏览器对多tab上传同一文件是否会做优化,如果确实会做优化,那我们也可以假设用户是同时在两款浏览器上同时上传某一个文件。

如上图所示,假设tab1先上传了3个分片,在上传第四个分片之前阻塞了(原因不详)。这个时候tab2开始上传,由于生产的文件标识一致,所以tab2的分片校验请求会对前3个分片返回true,这样tab2就会直接从第4个分片开始上传,我们假设tab2上传完第4个分片且再第5个分片开始上传前,tab1结束了阻塞状态,它会得知第4个分片已经上传完毕,迅速开始第5个分片的上传工作。

到目前为止,一切都是那么的顺利和合理,仿佛生活多么的美好。好,这个时候我们假设这个文件一共就需要5个分片就可以上传完毕。从图上可以看到,此时tab1和tab2会同时进行第5个分片的上传任务(因为在它们发送的分片校验请求时,彼此都没有完成上传,所以它们都会得到一个目标分片不存在须上传的回复)。

如果最终结果是tab1先上传完成了,那么依照我上文的做法,那么tab1会触发合并动作,合并过程中会清理已合并分片。那么问题就来了,会有以下几种不好的情况发生:

  1. tab1合并完分片1时,但tab2正在合并分片1,tab1删除分片1失败
  • tab1合并完分片1后成功删除分片1,导致tab2上传完分片5后无法触发合并动作(分片总数不达标),从而导致前端WU很尴尬(因为分片5上传完毕后就会触发uploadSuccess事件,但拿不到合并后的文件地址)
  • tab1合并完毕且删除了tmp文件,此时tab2上传完分片5,再次到回到情况2

好乱啊,总之上文中的方案不能很好的解决这个场景。那该怎么办好捏?

WU似乎应该是考虑过这种情况,所以提供了对应的Hook:after-send-file

在所有分片都上传完毕后,且没有错误后request,用来做分片验证,此时如果promise被reject,当前文件上传会触发错误。

我们只需要在这个hook中发送一个ajax请求给后端来触发合并动作,听起来这并没有解决并发啊,毕竟这个请求也可能同时到达后端!

是的,没错,但这么做有个好处,至少WU并不会尴尬,你可以告诉WU上传失败了,至少不会像之前的方案那样,明明触发了uploadSuccess事件,但却拿不到合并后的文件地址。

那么并发冲突怎么解决?我们就需要使用一种锁机制了,如果node平台,由于其执行主进程是单线程的,所以我们可以在全局变量中设置一个标志位即可。在java平台中,由于其依赖多线程实现并发,我们就要借助同步操作来解决这个问题。最头疼的就是php,老一点的手段就是让多个进程基于某个临时文件锁进行同步,当然也可以依赖第三方软件解决,比方说redis的原子操作,甚至再重一点引入zookeeper。

我会在上面提到的github地址中选择一种方案来解决这个问题的,敬请关注。