nodeJs + js 大檔案分片上傳

前端雜貨發表於2020-08-03

簡單的檔案上傳

一、準備檔案上傳的條件:

1、安裝nodejs環境

2、安裝vue環境

3、驗證環境是否安裝成功

二、實現上傳步驟

1、前端部分使用 vue-cli 腳手架,搭建一個 demo 版本,能夠實現簡單互動:

<template>
  <div id="app">
    <input type="file" @change="uploadFile"></button>
  </div>
</template>

2、安裝 axios 實現與後端互動:

import Axios from 'axios'

const Server = Axios.create({
  baseURL: '/api'
})

export default Server

3、後端使用 node-koa 框架:

// index.js
const Koa = require('koa');
const router = require('koa-router')() // koa路由元件
const fs = require('fs') // 檔案元件
const path = require('path') // 路徑元件
const koaBody = require('koa-body') //解析上傳檔案的外掛
const static = require('koa-static') // 訪問伺服器靜態資源元件

const uploadPath = path.join(__dirname, 'public/uploads') // 檔案上傳目錄

const app = new Koa(); // 例項化 koa

// 定義靜態資源訪問規則
app.use(static('public', {
  maxAge: 30 * 24 * 3600 * 1000 // 靜態資源快取時間 ms
}))

app.use(koaBody({
  multipart: true,
  formidable: {
uploadDir: uploadPath, maxFileSize:
10000 * 1024 * 1024 // 設定上傳檔案大小最大限制,預設20M } })) // 對於任何請求,app將呼叫該非同步函式處理請求: app.use(async (ctx, next) => { console.log(`Process ${ctx.request.method} ${ctx.request.url}...`); ctx.set('Access-Control-Allow-Origin', '*');//*表示可以跨域任何域名都行 也可以填域名錶示只接受某個域名 ctx.set('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type,token');//可以支援的訊息首部列表 ctx.set('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');//可以支援的提交方式 ctx.set('Content-Type', 'application/json;charset=utf-8');//請求頭中定義的型別 if (ctx.request.method === 'OPTIONS') { ctx.response.status = 200 } try { await next(); } catch (err) { console.log(err, 'errmessage') ctx.response.status = err.statusCode || err.status || 500 ctx.response.body = { errcode: 500, msg: err.message } ctx.app.emit('error', err, ctx); } })

4、前端實現上傳請求:

// vue
export default {
  name: 'App',
  methods: {
    uploadFile(e) {
      const file = e.target.files[0]
      this.sendFile(file)    
    },
    sendFile(file) {
      let formdata = new FormData()
      formdata.append("file", file)

      this.$http({
        url: "/upload/file",
        method: "post",
        data: formdata,
        headers: { "Content-Type": "multipart/form-data" }
      }).then(({ data }) => {
        console.log(data, 'upload/file')
      })
    }
  }  
}

5、node 接收檔案介面:

router.post('/api/upload/file', function uploadFile(ctx) {
  const files = ctx.request.files
  const filePath = path.join(uploadPath, files.file.name)

  // 建立可讀流
  const reader = fs.createReadStream(files['file']['path']);
  // 建立可寫流
  const upStream = fs.createWriteStream(filePath);
  // 可讀流通過管道寫入可寫流
  reader.pipe(upStream);

  ctx.response.body = {
    code: 0,
    url: path.join('http://localhost:3000/uploads', files.file.name),
    msg: '檔案上傳成功'
  }
})

以上全部過程就實現了一個簡單的檔案上傳功能。

 

 

 

這種實現方式上傳功能對於小檔案來說沒什麼問題,但當需求中碰到大檔案的時候,能解決上傳中遇到的各種問題,比如網速不好時、上傳速度慢、斷網情況、暫停上傳、重複上傳等問題。想要解決以上問題則需要優化前面的邏輯。

分片上傳

1、分片邏輯如下:

  • 由於前端已有 Blob Api 能操作檔案二進位制,因此最核心邏輯就是前端運用 Blob Api 對大檔案進行檔案分片切割,將一個大檔案切成一個個小檔案,然後將這些分片檔案一個個上傳。
  • 現在的 http 請求基本是 1.1 版本,瀏覽器能夠同時進行多個請求,這將用到一個叫 js 非同步併發控制的處理邏輯。
  • 當前端將所有分片上傳完成之後,前端再通知後端進行分片合併成檔案。

2、在進行檔案分片處理之前,先介紹下 js 非同步併發控制:

function sendRequest(arr, max = 6, callback) {
  let i = 0 // 陣列下標
  let fetchArr = [] // 正在執行的請求

  let toFetch = () => {
    // 如果非同步任務都已開始執行,剩最後一組,則結束併發控制
    if (i === arr.length) {
      return Promise.resolve()
    }

    // 執行非同步任務
    let it = fetch(arr[i++])
    // 新增非同步事件的完成處理
    it.then(() => {
      fetchArr.splice(fetchArr.indexOf(it), 1)
    })
    fetchArr.push(it)

    let p = Promise.resolve()
    // 如果併發數達到最大數,則等其中一個非同步任務完成再新增
    if (fetchArr.length >= max) {
      p = Promise.race(fetchArr)
    }

    // 執行遞迴
    return p.then(() => toFetch())
  }

  toFetch().then(() => 
    // 最後一組全部執行完再執行回撥函式
    Promise.all(fetchArr).then(() => {
      callback()
    })
  )
}

js 非同步併發控制的邏輯是:運用 Promise 功能,定義一個陣列 fetchArr,每執行一個非同步處理往 fetchArr 新增一個非同步任務,當非同步操作完成之後,則將當前非同步任務從 fetchArr 刪除,則當非同步 fetchArr 數量沒有達到最大數的時候,就一直往 fetchArr 新增,如果達到最大數量的時候,運用 Promise.race Api,每完成一個非同步任務就再新增一個,當所有最後一個非同步任務放進了 fetchArr 的時候,則執行 Promise.all,等全部完成之後則執行回撥函式。

上面這邏輯剛好適合大檔案分片上傳場景,將所有分片上傳完成之後,執行回撥請求後端合併分片。

前端改造:

1、定義一些全域性引數:

export default {
  name: 'App',
  data() {
    return {
      remainChunks: [], // 剩餘切片
      isStop: false, // 暫停上傳控制
      precent: 0, // 上傳百分比
      uploadedChunkSize: 0, // 已完成上傳的切片數
      chunkSize: 2 * 1024 * 1024 // 切片大小
    }
  }
}

2、檔案分割方法:

cutBlob(file) {
  const chunkArr = [] // 所有切片快取陣列
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice // 切割Api不同瀏覽器分割處理
  const spark = new SparkMD5.ArrayBuffer() // 檔案hash處理
  const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片總數

  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.readAsArrayBuffer(file)
    reader.addEventListener('loadend', () => {
      const content = reader.result

      // 生成檔案hash
      spark.append(content)
      const hash = spark.end()

      let startIndex = ''
      let endIndex = ''
      let contentItem = ''

      // 檔案切割
      for(let i = 0; i < chunkNums; i++) {
        startIndex = i * this.chunkSize
        endIndex = startIndex + this.chunkSize
        endIndex > file.size && (endIndex = file.size)

        contentItem = blobSlice.call(file, startIndex, endIndex)

        chunkArr.push({
          index: i,
          hash,
          total: chunkNums,
          name: file.name,
          size: file.size,
          chunk: contentItem
        })
      }
      resolve({
        chunkArr,
        fileInfo: {
          hash,
          name: file.name,
          size: file.size
        }
      })
    })
    reader.addEventListener('error', function _error(err) {
      reject(err)
    })
  })
}

以上方式的處理邏輯:定義一個切片快取陣列,當檔案進行分片之後,將快取所有的分片資訊、根據最大分片大小計算分片數量、計算整個檔案的 hash (spark-md5) 值,這將意味著,只要檔案內容不變,這 hash 值也將不變,這涉及到後面的秒傳功能、然後進行檔案分片。

 3、改造上傳方法:

async uploadFile(e) {
  const file = e.target.files[0]
  this.precent = 0
  this.uploadedChunkSize = 0
  // 如果檔案大於分片大小5倍,則進行分片上傳
  if (file.size < this.chunkSize * 5) {
    this.sendFile(file)
  } else {
    const chunkInfo = await this.cutBlob(file)
    this.remainChunks = chunkInfo.chunkArr
    this.fileInfo = chunkInfo.fileInfo

    this.mergeRequest()
  }
}

 注意:以上程式碼中設定當檔案大小大於分片大小的5倍進行分片上傳。

4、定義分片上傳請求(sendRequest)和合並請求(chunkMerge):

mergeRequest() {
  const chunks = this.remainChunks
  const fileInfo = this.fileInfo
  this.sendRequest(chunks, 6, () => {
    // 請求合併
    this.chunkMerge(fileInfo)
  })
}

 5、分片請求將結合上面提到的 JS 非同步併發控制:

sendRequest(arr, max = 6, callback) {
  let fetchArr = []

  let toFetch = () => {
    if (this.isStop) {
      return Promise.reject('暫停上傳')
    }
    if (!arr.length) {
      return Promise.resolve()
    }

    const chunkItem = arr.shift()
    const it = this.sendChunk(chunkItem)
    it.then(() => {
      fetchArr.splice(fetchArr.indexOf(it), 1)
    }, err => {
      this.isStop = true
      arr.unshift(chunkItem)
      Promise.reject(err)
    })
    fetchArr.push(it)

    let p = Promise.resolve()
    if (fetchArr.length >= max) {
      p = Promise.race(fetchArr)
    }

    return p.then(() => toFetch())
  }

  toFetch().then(() => {
    Promise.all(fetchArr).then(() => {
      callback()
    })
  }, err => {
    console.log(err)
  })
}

 6、切片上傳請求:

sendChunk(item) {
  let formdata = new FormData()
  formdata.append("file", item.chunk)
  formdata.append("hash", item.hash)
  formdata.append("index", item.index)
  formdata.append("name", item.name)

  return this.$http({
    url: "/upload/snippet",
    method: "post",
    data: formdata,
    headers: { "Content-Type": "multipart/form-data" },
    onUploadProgress: (e) => {
      const { loaded, total } = e
      this.uploadedChunkSize += loaded < total ? 0 : +loaded
      this.uploadedChunkSize > item.size && (this.uploadedChunkSize = item.size)

      this.precent = (this.uploadedChunkSize / item.size).toFixed(2) * 1000 / 10
    }
  })
}

 7、切片合併請求:

chunkMerge(data) {
  this.$http({
    url: "/upload/merge",
    method: "post",
    data,
  }).then(res => {
    console.log(res.data)
  })
}

前端處理檔案分片邏輯程式碼已完成

後端處理

後端部分就只新增兩個介面:分片上傳請求和分片合併請求:

1、分片上傳請求:

router.post('/api/upload/snippet', function snippet(ctx) {
  let files = ctx.request.files
  const { index, hash } = ctx.request.body

  // 切片上傳目錄
  const chunksPath = path.join(uploadPath, hash, '/')
  // 切片檔案
  const chunksFileName = chunksPath + hash + '-' + index

  if(!fs.existsSync(chunksPath)) {
    fs.mkdirSync(chunksPath)
  }
  // 秒傳,如果切片已上傳,則立即返回
  if (fs.existsSync(chunksFileName)) {
    ctx.response.body = {
      code: 0,
      msg: '切片上傳完成'
    }
    return
  }
  // 建立可讀流
  const reader = fs.createReadStream(files.file.path);
  // 建立可寫流
  const upStream = fs.createWriteStream(chunksFileName);
  // // 可讀流通過管道寫入可寫流
  reader.pipe(upStream);

  reader.on('end', () => {
    // 檔案上傳成功後,刪除本地切片檔案
    fs.unlinkSync(files.file.path)
  })
    
  ctx.response.body = {
    code: 0,
    msg: '切片上傳完成'
  }
})

2、分片合併請求:

/**
 * 1、判斷是否有切片hash資料夾
 * 2、判斷資料夾內的檔案數量是否等於total
 * 4、然後合併切片
 * 5、刪除切片檔案資訊
 */
router.post('/api/upload/merge', function uploadFile(ctx) {
  const { total, hash, name } = ctx.request.body
  const dirPath = path.join(uploadPath, hash, '/')
  const filePath = path.join(uploadPath, name) // 合併檔案

  // 已存在檔案,則表示已上傳成功
  if (fs.existsSync(filePath)) {
    ctx.response.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '檔案上傳成功'
    }
  // 如果沒有切片hash資料夾則表明上傳失敗
  } else if (!fs.existsSync(dirPath)) {
    ctx.response.body = {
      code: -1,
      msg: '檔案上傳失敗'
    }
  } else {
    const chunks = fs.readdirSync(dirPath) // 讀取所有切片檔案
    const fileWriteStream = fs.createWriteStream(filePath) // 建立可寫儲存檔案
    
    if(chunks.length !== total || !chunks.length) {
      ctx.response.body = {
        code: -1,
        msg: '上傳失敗,切片數量不符'
      }
    }

    for(let i = 0; i < chunks.length; i++) {
      // 將切片追加到儲存檔案
      fs.appendFileSync(filePath, fs.readFileSync(dirPath + hash + '-' + i))
      // 然後刪除切片
      fs.unlinkSync(dirPath + hash + '-' + i)
    }
    // 然後再刪除切片資料夾
    fs.rmdirSync(dirPath)
    // 預設情況下不需要手動關閉,但是在某些檔案的合併並不會自動關閉可寫流,比如壓縮檔案,所以這裡在合併完成之後,統一關閉下
    fileWriteStream.close()
    // 合併檔案成功
    ctx.response.body = {
      code: 0,
      url: path.join('http://localhost:3000/uploads', name),
      msg: '檔案上傳成功'
    }
  }
})

切片上傳成功與檔案合併截圖:

 

其它

1、前端暫停,續傳功能:

<template>
  <div id="app">
    <input type="file" @change="uploadFile">{{ precent }}%
    <button type="button" v-if="!isStop" @click="stopUpload">暫停</button>
    <button type="button" v-else @click="reupload">繼續上傳</button>
  </div>
</template>

2、js 新增主動暫停和續傳方法,比較簡單,這裡沒有做停止正在執行的請求:

stopUpload() {
  this.isStop = true
},
reupload() {
  this.isStop = false
  this.mergeRequest()
}

前端大檔案的分片上傳就差不多了。還可以優化的一點,在進行檔案 hash 求值的時候,大檔案的 hash 計算會比較慢,這裡可以加上 html5 的新特性,用 Web Worker 新開一個執行緒進行 hash 計算。

 

GitHub:https://github.com/554246839/file-upload

 

相關文章