字節跳動面試官,我也實現了大文件上傳和斷點續傳

前言

前幾天看到一個文章,感觸很深javascript

字節跳動面試官:請你實現一個大文件上傳和斷點續傳css

做者從0實現了大文件的切片上傳,斷點續傳,秒傳,暫停等功能,深刻淺出的把這個面試題進行了全面的剖析html

彩虹屁很少吹,我決定蹭蹭熱點,彔彔視頻,把做者完整寫代碼的過程加進去,而且接着這篇文章寫,因此請看完上面的文章後再食用,我作了一些擴展以下前端

  1. 計算hash耗時的問題,不只能夠經過web-workder,還能夠參考ReactFFiber架構,經過requestIdleCallback來利用瀏覽器的空閒時間計算,也不會卡死主線程
  2. 文件hash的計算,是爲了判斷文件是否存在,進而實現秒傳的功能,因此咱們能夠參考布隆過濾器的理念, 犧牲一點點的識別率來換取時間,好比咱們能夠抽樣算hash
  3. 文中經過web-workderhash計算不卡頓主線程,可是大文件因爲切片過多,過多的HTTP連接過去,也會把瀏覽器打掛 (我試了4個G的,直接卡死了), 咱們能夠經過控制異步請求的併發數來解決,我記得這也是頭條的一個面試題
  4. 每一個切片的上傳進度不須要用表格來顯示,咱們換成方塊進度條更直管一些(如圖)
  5. 併發上傳中,報錯如何重試,好比每一個切片咱們容許重試兩次,三次再終止
  6. 因爲文件大小不一,咱們每一個切片的大小設置成固定的也有點略顯笨拙,咱們能夠參考TCP協議的慢啓動策略, 設置一個初始大小,根據上傳任務完成的時候,來動態調整下一個切片的大小, 確保文件切片的大小和當前網速匹配
  7. 小的體驗優化,好比上傳的時候
  8. 文件碎片清理

已經存在的秒傳的切片就是綠的,正在上傳的是藍色的,併發量是4,廢話很少說,咱們一塊兒代碼開花java

時間切片計算文件hash

其實就是time-slice概念,ReactFiber架構的核心理念,利用瀏覽器的空閒時間,計算大的diff過程,中途又任何的高優先級任務,好比動畫和輸入,都會中斷diff任務, 雖然整個計算量沒有減少,可是大大提升了用戶的交互體驗node

這多是最通俗的 React Fiber(時間分片) 打開方式 react

requestIdleCallback

window.requestIdleCallback()方法將在瀏覽器的空閒時段內調用的函數排隊。這使開發者可以在主事件循環上執行後臺和低優先級工做 requestIdelCallback執行的方法,會傳遞一個deadline參數,可以知道當前幀的剩餘時間,用法以下ios

requestIdelCallback(myNonEssentialWork);
    
    
    function myNonEssentialWork (deadline) {
    
      // deadline.timeRemaining()能夠獲取到當前幀剩餘時間
      // 當前幀還有時間 而且任務隊列不爲空
      while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        doWorkIfNeeded();
      }
      if (tasks.length > 0){
        requestIdleCallback(myNonEssentialWork);
      }
    }

複製代碼

deadline的結構以下git

interface Dealine {
  didTimeout: boolean // 表示任務執行是否超過約定時間
  timeRemaining(): DOMHighResTimeStamp // 任務可供執行的剩餘時間
}
複製代碼

該圖中的兩個幀,在每一幀內部,TASKredering只花費了一部分時間,並無佔據整個幀,那麼這個時候,如圖中idle period的部分就是空閒時間,而每一幀中的空閒時間,根據該幀中處理事情的多少,複雜度等,消耗不等,因此空閒時間也不等。github

而對於每個deadline.timeRemaining()的返回值,就是如圖中,Idle Callback到所在幀結尾的時間(ms級)

時間切片計算

咱們接着以前文章的代碼,改造一下calculateHash

async calculateHashIdle(chunks) {
      return new Promise(resolve => {
        const spark = new SparkMD5.ArrayBuffer();
        let count = 0;
        // 根據文件內容追加計算
        const appendToSpark = async file => {
          return new Promise(resolve => {
            const reader = new FileReader();
            reader.readAsArrayBuffer(file);
            reader.onload = e => {
              spark.append(e.target.result);
              resolve();
            };
          });
        };
        const workLoop = async deadline => {
          // 有任務,而且當前幀還沒結束
          while (count < chunks.length && deadline.timeRemaining() > 1) {
            await appendToSpark(chunks[count].file);
            count++;
            // 沒有了 計算完畢
            if (count < chunks.length) {
              // 計算中
              this.hashProgress = Number(
                ((100 * count) / chunks.length).toFixed(2)
              );
              // console.log(this.hashProgress)
            } else {
              // 計算完畢
              this.hashProgress = 100;
              resolve(spark.end());
            }
          }
          window.requestIdleCallback(workLoop);
        };
        window.requestIdleCallback(workLoop);
      });
    },
複製代碼

計算過程當中,頁面放個輸入框,輸入無壓力,時間切片的威力

上圖是 React15和 Fiber架構的對比,能夠看出下圖任務量沒邊,可是變得零散了,不混卡頓主線程

抽樣hash

計算文件md5值的做用,無非就是爲了斷定文件是否存在,咱們能夠考慮設計一個抽樣的hash,犧牲一些命中率的同時,提高效率,設計思路以下

  1. 文件切成2M的切片
  2. 第一個和最後一個切片所有內容,其餘切片的取 首中尾三個地方各2個字節
  3. 合併後的內容,計算md5,稱之爲影分身Hash
  4. 這個hash的結果,就是文件存在,有小几率誤判,可是若是不存在,是100%準的的 ,和布隆過濾器的思路有些類似, 能夠考慮兩個hash配合使用
  5. 我在本身電腦上試了下1.5G的文件,全量大概要20秒,抽樣大概1秒仍是很不錯的, 能夠先用來判斷文件是否是不存在
  6. 我真是個小機靈

抽樣md5: 1028.006103515625ms

全量md5: 21745.13916015625ms

複製代碼
async calculateHashSample() {
      return new Promise(resolve => {
        const spark = new SparkMD5.ArrayBuffer();
        const reader = new FileReader();
        const file = this.container.file;
        // 文件大小
        const size = this.container.file.size;
        let offset = 2 * 1024 * 1024;

        let chunks = [file.slice(0, offset)];

        // 前面100K

        let cur = offset;
        while (cur < size) {
          // 最後一塊所有加進來
          if (cur + offset >= size) {
            chunks.push(file.slice(cur, cur + offset));
          } else {
            // 中間的 前中後去兩個字節
            const mid = cur + offset / 2;
            const end = cur + offset;
            chunks.push(file.slice(cur, cur + 2));
            chunks.push(file.slice(mid, mid + 2));
            chunks.push(file.slice(end - 2, end));
          }
          // 前取兩個字節
          cur += offset;
        }
        // 拼接
        reader.readAsArrayBuffer(new Blob(chunks));
        reader.onload = e => {
          spark.append(e.target.result);

          resolve(spark.end());
        };
      });
    }
複製代碼

網絡請求併發控制

大文件hash計算後,一次發幾百個http請求,計算哈希沒卡,結果TCP創建的過程就把瀏覽器弄死了,並且我記得自己異步請求併發數的控制,自己就是頭條的一個面試題

思路其實也不難,就是咱們把異步請求放在一個隊列裏,好比並發數是3,就先同時發起3個請求,而後有請求結束了,再發起下一個請求便可, 思路清楚,代碼也就呼之欲出了

咱們經過併發數max來管理併發數,發起一個請求max--,結束一個請求max++便可

+async sendRequest(forms, max=4) {
+ return new Promise(resolve => {
+ const len = forms.length;
+ let idx = 0;
+ let counter = 0;
+ const start = async ()=> {
+ // 有請求,有通道
+ while (idx < len && max > 0) {
+ max--; // 佔用通道
+ console.log(idx, "start");
+ const form = forms[idx].form;
+ const index = forms[idx].index;
+ idx++
+ request({
+ url: '/upload',
+ data: form,
+ onProgress: this.createProgresshandler(this.chunks[index]),
+ requestList: this.requestList
+ }).then(() => {
+ max++; // 釋放通道
+ counter++;
+ if (counter === len) {
+ resolve();
+ } else {
+ start();
+ }
+ });
+ }
+ }
+ start();
+ });
+}

async uploadChunks(uploadedList = []) {
  // 這裏一塊兒上傳,遇見大文件就是災難
  // 沒被hash計算打到,被一次性的tcp連接把瀏覽器稿掛了
  // 異步併發控制策略,我記得這個也是頭條一個面試題
  // 好比並髮量控制成4
  const list = this.chunks
    .filter(chunk => uploadedList.indexOf(chunk.hash) == -1)
    .map(({ chunk, hash, index }, i) => {
      const form = new FormData();
      form.append("chunk", chunk);
      form.append("hash", hash);
      form.append("filename", this.container.file.name);
      form.append("fileHash", this.container.hash);
      return { form, index };
    })
- .map(({ form, index }) =>
- request({
- url: "/upload",
- data: form,
- onProgress: this.createProgresshandler(this.chunks[index]),
- requestList: this.requestList
- })
- );
- // 直接全量併發
- await Promise.all(list);
     // 控制併發  
+ const ret = await this.sendRequest(list,4)

  if (uploadedList.length + list.length === this.chunks.length) {
    // 上傳和已經存在之和 等於所有的再合併
    await this.mergeRequest();
  }
},
複製代碼

話說字節跳動另一個面試題我也作出來的,不知道能不能經過他們的一面

慢啓動策略實現

TCP擁塞控制的問題 其實就是根據當前網絡狀況,動態調整切片的大小

  1. chunk中帶上size值,不過進度條數量不肯定了,修改createFileChunk, 請求加上時間統計)
  2. 好比咱們理想是30秒傳遞一個
  3. 初始大小定爲1M,若是上傳花了10秒,那下一個區塊大小變成3M
  4. 若是上傳花了60秒,那下一個區塊大小變成500KB 以此類推
  5. 併發+慢啓動的邏輯有些複雜,我本身還沒繞明白,囧因此先一次只傳一個切片,來演示這個邏輯,新建一個handleUpload1函數
async handleUpload1(){
      // @todo數據縮放的比率 能夠更平緩  
      // @todo 併發+慢啓動

      // 慢啓動上傳邏輯 
      const file = this.container.file
      if (!file) return;
      this.status = Status.uploading;
      const fileSize = file.size
      let offset = 1024*1024 
      let cur = 0 
      let count =0
      this.container.hash = await this.calculateHashSample();

      while(cur<fileSize){
        // 切割offfset大小
        const chunk = file.slice(cur, cur+offset)
        cur+=offset
        const chunkName = this.container.hash + "-" + count;
        const form = new FormData();
        form.append("chunk", chunk);
        form.append("hash", chunkName);
        form.append("filename", file.name);
        form.append("fileHash", this.container.hash);
        form.append("size", chunk.size);

        let start = new Date().getTime()
        await request({ url: '/upload',data: form })
        const now = new Date().getTime()
        
        const time = ((now -start)/1000).toFixed(4)
        let rate = time/30
        // 速率有最大2和最小0.5
        if(rate<0.5) rate=0.5
        if(rate>2) rate=2
        // 新的切片大小等比變化
        console.log(`切片${count}大小是${this.format(offset)},耗時${time}秒,是30秒的${rate}倍,修正大小爲${this.format(offset/rate)}`)
        // 動態調整offset
        offset = parseInt(offset/rate)
        // if(time)
        count++
      }
    }

複製代碼

調整下slow 3G網速 看下效果

切片0大小是1024.00KB,耗時13.2770秒,是30秒的0.5倍,修正大小爲2.00MB
切片1大小是2.00MB,耗時25.4130秒,是30秒的0.8471倍,修正大小爲2.36MB
切片2大小是2.36MB,耗時14.1260秒,是30秒的0.5倍,修正大小爲4.72MB
複製代碼

搞定

進度條優化

這就屬於小優化了,方便咱們查看存在的文件區塊和併發數,靈感來自於硬盤掃描

<div class="cube-container" :style="{width:cubeWidth+'px'}">
    <div class="cube" v-for="chunk in chunks" :key="chunk.hash">
      <div :class="{ 'uploading':chunk.progress>0&&chunk.progress<100, 'success':chunk.progress==100 }" :style="{height:chunk.progress+'%'}" >
        
        <i v-if="chunk.progress>0&&chunk.progress<100" class="el-icon-loading" style="color:#F56C6C;"></i>
      </div>
    </div>
  </div>
複製代碼
.cube-container
  width 100px
  overflow hidden
.cube
  width 14px
  height 14px
  line-height 12px;
  border 1px solid black
  background  #eee
  float left
  >.success
    background #67C23A
  >.uploading
    background #409EFF

複製代碼
// 方塊進度條儘量的正方形 切片的數量平方根向上取整 控制進度條的寬度

cubeWidth(){
  return Math.ceil(Math.sqrt(this.chunks.length))*16
},

複製代碼

效果還能夠 再看一遍🐶

併發重試+報錯

  1. 請求出錯.catch 把任務從新放在隊列中
  2. 出錯後progress設置爲-1 進度條顯示紅色
  3. 數組存儲每一個文件hash請求的重試次數,作累加 好比[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次
  4. 超過3的直接reject

首前後端模擬報錯

if(Math.random()<0.5){
        // 機率報錯
        console.log('機率報錯了')
        res.statusCode=500
        res.end()
        return 
      }

複製代碼
async sendRequest(urls, max=4) {
- return new Promise(resolve => {
+ return new Promise((resolve,reject) => {
         const len = urls.length;
         let idx = 0;
         let counter = 0;
+ const retryArr = []

 
         const start = async ()=> {
           // 有請求,有通道
- while (idx < len && max > 0) {
+ while (counter < len && max > 0) {
             max--; // 佔用通道
             console.log(idx, "start");
- const form = urls[idx].form;
- const index = urls[idx].index;
- idx++
+ // 任務不能僅僅累加獲取,而是要根據狀態
+ // wait和error的能夠發出請求 方便重試
+ const i = urls.findIndex(v=>v.status==Status.wait || v.status==Status.error )// 等待或者error
+ urls[i].status = Status.uploading
+ const form = urls[i].form;
+ const index = urls[i].index;
+ if(typeof retryArr[index]=='number'){
+ console.log(index,'開始重試')
+ }
             request({
               url: '/upload',
               data: form,
               onProgress: this.createProgresshandler(this.chunks[index]),
               requestList: this.requestList
             }).then(() => {
+ urls[i].status = Status.done
               max++; // 釋放通道
               counter++;
+ urls[counter].done=true
               if (counter === len) {
                 resolve();
               } else {
                 start();
               }
- });
+ }).catch(()=>{
+ urls[i].status = Status.error
+ if(typeof retryArr[index]!=='number'){
+ retryArr[index] = 0
+ }
+ // 次數累加
+ retryArr[index]++
+ // 一個請求報錯3次的
+ if(retryArr[index]>=2){
+ return reject()
+ }
+ console.log(index, retryArr[index],'次報錯')
+ // 3次報錯之內的 重啓
+ this.chunks[index].progress = -1 // 報錯的進度條
+ max++; // 釋放當前佔用的通道,可是counter不累加
+ 
+ start()
+ })
           }
         }
         start();

}

複製代碼

如圖所示,報錯後會區塊變紅,可是會重試

文件碎片清理

若是不少人傳了一半就離開了,這些切片存在就沒意義了,能夠考慮按期清理,固然 ,咱們可使用node-schedule來管理定時任務 好比咱們天天掃一次target,若是文件的修改時間是一個月之前了,就直接刪除把

// 爲了方便測試,我改爲每5秒掃一次, 過時1鐘的刪除作演示
const fse = require('fs-extra')
const path = require('path')
const schedule = require('node-schedule')


// 空目錄刪除
function remove(file,stats){
    const now = new Date().getTime()
    const offset = now - stats.ctimeMs 
    if(offset>1000*60){
        // 大於60秒的碎片
        console.log(file,'過時了,浪費空間的玩意,刪除')
        fse.unlinkSync(file)
    }
}

async function scan(dir,callback){
    const files = fse.readdirSync(dir)
    files.forEach(filename=>{
        const fileDir = path.resolve(dir,filename)
        const stats = fse.statSync(fileDir)
        if(stats.isDirectory()){
            return scan(fileDir,remove)
        }
        if(callback){
            callback(fileDir,stats)
        }
    })
}
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// │ │ │ │ │ │
// │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
// │ │ │ │ └───── month (1 - 12)
// │ │ │ └────────── day of month (1 - 31)
// │ │ └─────────────── hour (0 - 23)
// │ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
let start = function(UPLOAD_DIR){
    // 每5秒
    schedule.scheduleJob("*/5 * * * * *",function(){
        console.log('開始掃描')
        scan(UPLOAD_DIR)
    })
}
exports.start = start

複製代碼
開始掃描
/upload/target/625c.../625c...-0 過時了,刪除
/upload/target/625c.../625c...-1 過時了,刪除
/upload/target/625c.../625c...-10 過時了,刪除
/upload/target/625c.../625c...-11 過時了,刪除
/upload/target/625c.../625c...-12 過時了,刪除


複製代碼

後續擴展和思考

留幾個思考題,下次寫文章再實現 方便繼續蹭熱度

  1. requestIdleCallback兼容性,如何本身實現一個
    1. react也是本身寫的調度邏輯,之後有機會寫個文章介紹
    2. React本身實現的requestIdleCallback
  2. 併發+慢啓動配合
  3. 抽樣hash+全量哈希+時間切片配合
  4. 大文件切片下載
    1. 同樣的切片邏輯,經過axios.head請求獲取content-Length
    2. 使用http的Range這個header就能夠切片下載了,其餘邏輯和上傳差很少
  5. 小的體驗優化
    1. 好比離開頁面的提醒 等等小tips
  6. 慢啓動的變化應該更平滑,好比使用三角函數,把變化率平滑的限制在0.5~1.5之間
  7. websocket推送進度

思考和總結

  1. 任何看似簡單的需求,量級提高後,都變得很複雜,人生也是這樣
  2. 文件上傳簡單,大文件就複雜,單機簡單,分佈式難
  3. 就連一個簡單的leftPad函數(左邊補齊字符),考慮到性能,二分法性能都秒殺數組join ,引發大討論
    1. 論left-pad函數的實現
    2. 任何一個知識點 都值得深挖
  4. 產品經理下次再說啥需求簡單,就kan他
  5. 我準備結合上一篇,錄一個大文件上傳的手摸手剖析視頻 敬請期待

代碼

前半段抄襲了@yeyan1996的代碼,後面代碼主要爲了講明思路,實現的比較粗糙,求輕噴 github.com/shengxinjin…

歡迎關注

參考資料

有些圖也是我直接從下面鏈接中copy的

  1. 寫給新手前端的各類文件上傳攻略
  2. 字節跳動面試官:請你實現一個大文件上傳和斷點續傳
  3. 這多是最通俗的 React Fiber(時間分片) 打開方式
  4. 大白話布隆過濾器
  5. requestIdleCallback
  6. TCP擁塞控制的問題
  7. 面試題解析丨Python實現文件切片下載
  8. requestIdleCallback-後臺任務調度
相關文章
相關標籤/搜索