码农日记

薄洪涛的个人博客

大文件之分片上传

    我们知道,无论是nginx还是php,都会对上传文件的大小做限制,今天刚刚做了一个客户端包的管理,要求上传apk或者ipa文件,而且都不小(超出了php和nginx的允许范围),那我们如何上传呢?

    其实这里就用到了分片上传技术,所谓的分片,就是在本地(客户端)将文件拆分成很多的临时文件,然后上传这些临时文件到服务器的某个文件夹下,当最后一片上传完成后,在服务器端进行拼接合并;

    传统的表单上传适用于文件内容可以在一次 HTTP 请求即可传递完成的场景。该功能非常适合在浏览器中使用 HTML 表单上传资源,或者在不需要处理复杂情况的客户端开发中使用。如果文件较大(大于 1GB),或者网络环境较差,可能会导致 HTTP 连接超时而上传失败。若发生这种情况,您需要考虑换用更安全的分片上传功能。

    分片上传支持将一个文件切割为一系列特定大小的数据片,分别将这些小数据片上传到服务端,全部上传完后再在服务端将这些数据片合并成为一个资源。

    分片上传引入了两个概念:块(Block)和片(Chunk)。每个块由一到多个片组成,而一个资源则由一到多个块组成。他们之间的关系可以用下图表述

     2.jpg

    块和片是上传过程中作为临时存储的单位。服务端会以约七天为单位的周期清除上传后未被合并为块(文件)的数据片(块)。

    与分片上传相关的 API 有:创建块(mkblk)上传片(bput)创建文件(mkfile)。一个完整的分片上传流程可用下图表示:

     1.jpg

    其中的关键点如下:

  • 将待上传的文件按预定义块大小切分为若干个块(每块大小不大于 4MB)。如果这个文件小于 4MB,就只有一个块。

  • 将每个块再按预定义的片大小切分为若干个片,先在服务端创建一个相应块(通过调用mkblk,并带上第一个片的内容),然后再循环将所有剩下的片全部上传(通过调用bput,从而完成一个块的上传)

  • 在所有块上传完成后,通过调用mkfile将这些上传完成的块信息再严格的按顺序组装出一个逻辑资源的元信息,从而完成整个资源的分片上传过程。


    前端页面

<form id="upload_form" name="upload_form" action="javascript:init();" method="post" enctype="multipart/form-data">
    <div>
        <label class="col-xs-2 control-label" for="appversion-type" style="text-align: right">上传客户端</label>
        <input class="col-xs-6 control-label" type="file" id="file" name="file" onchange="fileReady()">
        <input type="submit" id="submit" name="submit" value="上传" class="ladda-button btn btn-primary" >
        <button id="clear" class="btn btn-warning btn-outline" onclick="clearUploadFile()">清除</button>
        <div class="help-block"></div>
    </div>
    <div class="well form-group upload_message_show" style="display: none;" id="down_process">
        <input type="hidden" value="index.php?r=你自己的上传接口" id="upload_action" name="upload_action" />
        <small>
            <span class="upload_file_type hidden"></span>
            <span class="upload_file_name"></span>
            (<span class="isCompleted">上传状态:</span>)
        </small>
        <div class="stat-percent upload_percent"></div>
        <div class="progress progress-mini">
            <div style="width: 0%;" class="progress-bar upload_bar"></div>
        </div>
        <p>
            <small class="m-r-xl">速度 <span class="speed"></span></small>
            <small class="m-r-xl"><span class="upload_file_size"></span></small>
            <small class="m-r-xl left_time">剩余时间 00:00:00</small>
        </p>
        <p id="qiniu" style="display: none">
            <small class="m-r-xl">上传七牛成功,保存链接为:<span class="speed" id="qiniu_url"></span></small>
        </p>
        <small class="upload_file_error">
            <span class="text-danger"></span>
        </small>
        <small class="upload_file_preview hidden"></small>
    </div>
</form>

    js文件

var nSlice_count = 100,//分段数
   nFactCount,          //实际分段数
   nMin_size   = 0.5,//最小分段大小(M)
   nMax_size   = 5,  //最大分段大小(M)
   nFactSize,       //实际分段大小
   nCountNum   = 0,  //分段标号
   sFile_type,          //文件类型
   nFile_load_size,   //文件上传部分大小
   nFile_size,          //文件大小
   nPreuploaded = 0,  //上一次记录上传部分的大小
   bIs_uploading= false,//是否上传中
   bStart_upload= false,//是否开始上传
   bEnd_upload  = false;//是否上传完成


function init(){
   var $con = document.getElementById("submit").value;

   bStart_upload = ($con=="上传"?true:false);
   if(bStart_upload)
   {
      if(!bEnd_upload)
      document.getElementById("submit").value = "暂停";
   }
   else
   {
      clearTimeout('timer');
      document.getElementById("submit").value = "上传";
   }
   if(!bEnd_upload && bStart_upload)
   startUpload();

}

function startUpload(){

   var form = document.forms["upload_form"];
   if(form["file"].files.length<=0)
   {
      alert("请先选择文件,然后再点击上传");
      return;
   }

   var file = form["file"].files[0];

   var get_file_message = (function(){

      var get_message = {
         get_name:function(){
            return file.name;
         },
         get_type:function(){
            return file.type;

         },
         get_size:function(){
            return file.size;
         },
         getAll:function(){
            return {
               fileName : this.get_name(),
               fileSize : this.get_size(),
               fileType : this.get_type()
            }
         }
      };
      return get_message;
   })();

   var conversion = (function(){
      var unitConversion = {
         bytesTosize:function(data){
            var unit = ["Bytes","KB","MB","GB"];
            var i = parseInt(Math.log(data)/Math.log(1024));
            return (data/Math.pow(1024,i)).toFixed(1) + " " + unit[i];
         },
         secondsTotime:function(sec){
            var h = Math.floor(sec/3600),
               m = Math.floor((sec-h*3600)/60),
               s = Math.floor(sec-h*3600-m*60);
            if(h<10) h = "0" + h;
            if(m<10) m = "0" + m;
            if(s<10) s = "0" + s;

            return h + ":" + m + ":" + s ;
         }
      };

      return unitConversion;
   })();

   //start sending
   var reader = new FileReader();
   var timer;

   var fProgress = function(e){
      var fSize = get_file_message.getAll().fileSize;
      timer = setTimeout(uploadCount(e,fSize,conversion),300);
   };

   var floadend = function(e){
      if(reader.error){alert("上传失败,出现未知错误");clearTimeout(timer);return;}
      clearTimeout(timer);
      if(nCountNum+1!=nFactCount)
      {
         if(bStart_upload)
         {
            nCountNum++;
            uploadStart();
            return;
         } else {
            document.querySelector(".speed").innerHTML = "0k/s";
            document.querySelector(".left_time").innerHTML = "剩余时间  00:00:00";
            return;
         }
      }

      bEnd_upload = true;
      document.querySelector(".speed").innerHTML = "0k/s";
      document.querySelector(".left_time").innerHTML = "剩余时间  00:00:00";
      document.querySelector(".upload_percent").innerHTML = "100.00%";
      document.getElementById("submit").value = "上传";
      document.querySelector(".upload_bar").style.width = "100%";
      console.log(e.target.responseText);

      var $res = JSON.parse(e.target.responseText);
      filePreview($res);
      if($res.res=="success") {
         bIs_uploading = true;
         document.getElementById('upload_field').value = $res.url;
         document.getElementById('qiniu_url').innerText = $res.url;
         document.getElementById('qiniu').style.display = 'block';
      }
      document.querySelector(".isCompleted").innerHTML="上传状态: " + (bIs_uploading?"上传完成":"正在上传..");
   };

   var uploadStart = function(){
      var get_all = get_file_message.getAll();
      var start = nCountNum * nFactSize,
         end   = Math.min(start+nFactSize,get_all.fileSize);

      var fData = new FormData();

      fData.append("file",file.slice(start,end));
      fData.append("name",file.name);
      fData.append("size",file.size);
      fData.append("type",file.type);
      fData.append("totalCount",nFactCount);
      fData.append("indexCount",nCountNum);
      fData.append("trueName",file.name.substring(0,file.name.lastIndexOf(".")));

      if(!sFile_type)
      sFile_type = file.type.substring(0,file.type.indexOf("/"));
      var xhr = new XMLHttpRequest();
      xhr.upload.addEventListener("progress",fProgress,false);
      xhr.addEventListener("load",floadend,false);
      xhr.addEventListener("error",errorUp,false);
      xhr.addEventListener("abort",abortUp,false);

      var url = document.getElementById("upload_action").value;
      console.log(url);
      xhr.open("POST",url);
      xhr.send(fData);
   };

   reader.onloadstart = function(){
      var get_all = get_file_message.getAll(),
         fName = get_all.fileName,
         fType = get_all.fileType,
         fSize = conversion.bytesTosize(get_all.fileSize);

      document.querySelector(".upload_message_show").style.display = "block";
      document.querySelector(".upload_file_name").innerHTML ="文件名称: " + fName;
      document.querySelector(".upload_file_type").innerHTML ="文件类型: " + fType;
      document.querySelector(".upload_file_size").innerHTML ="文件大小: " + fSize;
      document.querySelector(".isCompleted").innerHTML     ="上传状态: " + (bIs_uploading?"完成":"正在上传中..");

      nFactSize = get_all.fileSize/nSlice_count;
      nFactSize = (nFactSize>=nMin_size*1024*1024?nFactSize:nMin_size*1024*1024);
      nFactSize = (nFactSize<=nMax_size*1024*1024?nFactSize:nMax_size*1024*1024);
      nFactCount= Math.ceil(get_all.fileSize/nFactSize);

      uploadStart();
   };
   reader.readAsBinaryString(file);
}

function uploadCount(e,fSize,conversion){
   var upSize = e.loaded+nCountNum*nFactSize,
      perc = (upSize*100/fSize).toFixed(2) + "%";
   var speed = Math.abs(upSize - nPreuploaded);
   if(speed==0){clearTimeout("timer");return;}
   if(perc=="100.01%") {perc="100%";}
   var leftTime = conversion.secondsTotime(Math.round((fSize-upSize)/speed));
   speed = conversion.bytesTosize(speed)+"/s";
   document.querySelector(".speed").innerHTML = speed;
   document.querySelector(".left_time").innerHTML = "剩余时间  " + leftTime;
   document.querySelector(".upload_percent").innerHTML = perc;
   console.log(perc);
   document.querySelector(".upload_bar").style.width = perc;
   nPreuploaded = upSize;
}

function messageChange(){
   document.querySelector(".upload_file_name").innerHTML ="文件名称: " ;
   document.querySelector(".upload_file_type").innerHTML ="文件类型: " ;
   document.querySelector(".upload_file_size").innerHTML ="文件大小: " ;
   document.querySelector(".isCompleted").innerHTML     ="上传状态: " ;
   document.querySelector(".upload_bar").style.width = "0%";
   document.querySelector(".upload_percent").innerHTML = "0%";
   document.querySelector(".upload_file_preview").innerHTML ="";
   document.querySelector(".upload_message_show").style.display = "none";
}

function clearUploadFile(){
   var e = e || event;
   e.stopPropagation();
   e.preventDefault();
   document.getElementById("file").value = "";
   bStart_upload = false;
   messageChange();
}


function fileReady(){
   bIs_uploading = false;
   bEnd_upload = false;
   nCountNum = 0;
   bStart_upload = false;
   messageChange();
}


function errorUp(){
   bStart_upload = false;
   document.querySelector(".upload_file_error").innerHTML = "上传过程中出错";
}

function abortUp(){
   bStart_upload = false;
   document.querySelector(".upload_file_error").innerHTML = "网络故障,请检查重试";
}

function filePreview($src){
   var ftype = sFile_type;
   var $temp;
   var IMGMaxHeight = document.querySelector(".upload_message_show").offsetHeight;
   switch(ftype){
      case "image" :
      $temp = '<img src="source/'+$src.url+'" style="max-height:'+IMGMaxHeight+'px;margin-left:30%;">';
      break;
      case "audio" :
      $temp = '<audio src="source/'+$src.url+'" controls="controls"></audio>';
      break;
      case "video" :
      $temp = '<video src="source/'+$src.url+'" controls="controls"></video>';
      break;
   }
   var IsPreview = checkUserAgent();

   if(IsPreview)
   document.querySelector(".upload_file_preview").innerHTML = $temp;
}

function checkUserAgent(){
   var msg = true;
   var agent = ["ipod","iphone","android","symbian","windows mobile"];
   var info =navigator.userAgent.toLowerCase();

   for(var i=0,j=agent.length;i<j;i++)
   {
      if(info.indexOf(agent[i])>0)
      msg = false;
   }

   return msg;
}

后端php(完成临时文件tmp和文件的拼接)

/*
 * 断点续传
 */
public function actionUpload()
{
    $fsize = $_POST['size'];
    $findex = $_POST['indexCount'];
    $ftotal = $_POST['totalCount'];
    $ftype = $_POST['type'];
    $fdata = $_FILES['file'];
    $fname = mb_convert_encoding($_POST['name'], "gbk", "utf-8");
    $truename = mb_convert_encoding($_POST['trueName'], "gbk", "utf-8");
    $dir = "uploads/" . $truename . "-" . $fsize;
    //检测文件夹是否存在
    if (!file_exists($dir)) {
        mkdir($dir, 0777, true);
    }
    $save = $dir . "/" . $fname;
    if (!is_dir($dir)) {
        mkdir($dir);
        chmod($dir, 0777);
    }
    //读取临时文件内容
    $temp = fopen($fdata["tmp_name"], "r+");
    $filedata = fread($temp, filesize($fdata["tmp_name"]));
    //将分段内容存放到新建的临时文件里面
    if (file_exists($dir . "/" . $findex . ".tmp")) unlink($dir . "/" . $findex . ".tmp");
    $tempFile = fopen($dir . "/" . $findex . ".tmp", "w+");
    fwrite($tempFile, $filedata);
    fclose($tempFile);
    fclose($temp);
    if ($findex + 1 == $ftotal) {
        if (file_exists($save)) @unlink($save);
        //循环读取临时文件并将其合并置入新文件里面
        for ($i = 0; $i < $ftotal; $i++) {
            $readData = fopen($dir . "/" . $i . ".tmp", "r+");
            $writeData = fread($readData, filesize($dir . "/" . $i . ".tmp"));
            $newFile = fopen($save, "a+");
            fwrite($newFile, $writeData);
            fclose($newFile);
            fclose($readData);
            $resu = @unlink($dir . "/" . $i . ".tmp");
        }
        $save = \Yii::$app->params['baseUrlPrefix'] . $save;
        //这里是我自己封装的上传到七牛的方法,你可以自己返回服务器的文件链接
        $qiniu_url =Utils::saveQiniuByUrl($save);
        if($qiniu_url!==false){
            $res = ['res'=>'success','url'=>$qiniu_url];
        }else{
            $res = ['res'=>'error'];
        }
        return json_encode($res);
    }
}

其实这地方没有太难的地方,主要就是想记录下代码,另外,如果转存到七牛的话,服务器上的文件最好可以删掉

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

Powered By Z-BlogPHP 1.7.3

版权所有 | 转载请标明出处