【轉(zhuǎn)載】node+js實現(xiàn)大文件分片上傳

原文鏈接:https://www.cnblogs.com/goloving/p/12826067.html

1柬讨、什么是分片上傳

分片上傳就是把一個大的文件分成若干塊蔗牡,一塊一塊的傳輸趁啸。這樣做的好處可以減少重新上傳的開銷访娶。比如:如果我們上傳的文件是一個很大的文件,那么上傳的時間應該會比較久咳蔚,再加上網(wǎng)絡不穩(wěn)定各種因素的影響,很容易導致傳輸中斷扭粱,用戶除了重新上傳文件外沒有其他的辦法迂猴,但是我們可以使用分片上傳來解決這個問題掷倔。通過分片上傳技術凯旋,如果網(wǎng)絡傳輸中斷,我們重新選擇文件只需要傳剩余的分片傍菇。而不需要重傳整個文件,大大減少了重傳的開銷揽思。

但是我們要如何選擇一個合適的分片呢辽社?因此我們要考慮如下幾個事情:

1. 分片越小滴铅,那么請求肯定越多噩翠,開銷就越大。因此不能設置太小。
  2. 分片越大,靈活度就少了缸废。
  3. 服務器端都會有個固定大小的接收Buffer。分片的大小最好是這個值的整數(shù)倍缩多。

因此呆奕,綜合考慮到推薦分片的大小是2M-5M,具體分片的大小需要根據(jù)文件的大小來確定衬吆,如果文件太大梁钾,建議分片的大小是5M,如果文件相對較小逊抡,那么建議分片的大小是2M姆泻。

實現(xiàn)文件分片上傳的步驟如下:

1. 先對文件進行md5加密。使用md5加密的優(yōu)點是:可以對文件進行唯一標識冒嫡,同樣可以為后臺進行文件完整性校驗進行比對拇勃。
  2. 拿到md5值以后,服務器端查詢下該文件是否已經(jīng)上傳過孝凌,如果已經(jīng)上傳過的話方咆,就不用重新再上傳。
  3. 對大文件進行分片蟀架。比如一個100M的文件瓣赂,我們一個分片是5M的話,那么這個文件可以分20次上傳片拍。
  4. 向后臺請求接口煌集,接口里的數(shù)據(jù)就是我們已經(jīng)上傳過的文件塊。(注意:為什么要發(fā)這個請求捌省?就是為了能斷點續(xù)傳苫纤,比如我們使用百度網(wǎng)盤對吧,網(wǎng)盤里面有續(xù)傳功能,當一個文件傳到一半的時候卷拘,突然想下班不想上傳了喊废,那么服務器就應該記住我之前上傳過的文件塊,當我打開電腦重新上傳的時候恭金,那么它應該跳過我之前已經(jīng)上傳的文件塊操禀。再上傳后續(xù)的塊)。
  5. 開始對未上傳過的文件塊進行上傳横腿。(這個是第二個請求颓屑,會把所有的分片合并,然后上傳請求)耿焊。
  6. 上傳成功后揪惦,服務器會進行文件合并。最后完成罗侯。

2器腋、理解Blob對象中的slice方法對文件進行分割及其他知識點

可以看下我之前的博客:利用blob對象實現(xiàn)大文件分片上傳

Blob對象自身有 size 和 type兩個屬性,及它的原型上有 slice() 方法钩杰。我們可以通過該方法來切割我們的二進制的Blob對象纫塌。

blob.slice(startByte, endByte) 是Blob對象中的一個方法,F(xiàn)ile對象它是繼承Blob對象的讲弄,因此File對象也有該slice方法的措左。

參數(shù):
    startByte: 表示文件起始讀取的Byte字節(jié)數(shù)。
    endByte: 表示結(jié)束讀取的字節(jié)數(shù)避除。

返回值:var b = new Blob(startByte, endByte); 該方法的返回值仍然是一個Blob類型怎披。

我們可以使用 blob.slice() 方法對二進制的Blob對象進行切割,但是該方法也是有瀏覽器兼容性的瓶摆,因此我們可以封裝一個方法:如下所示:

function blobSlice(blob, startByte, endByte) {
  if (blob.slice) {
    return blob.slice(startByte, endByte);
  }
  // 兼容firefox
  if (blob.mozSlice) {
    return blob.mozSlice(startByte, endByte);
  }
  // 兼容webkit
  if (blob.webkitSlice) {
    return blob.webkitSlice(startByte, endByte);
  }
  return null;
}

3凉逛、具體實現(xiàn)

$(document).ready(() => {
  const chunkSize = 2 * 1024 * 1024; // 每個chunk的大小,設置為2兆
  // 使用Blob.slice方法來對文件進行分割群井。
  // 同時該方法在不同的瀏覽器使用方式不同状飞。
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
  const hashFile = (file) => {
    return new Promise((resolve, reject) => { 
      const chunks = Math.ceil(file.size / chunkSize);
      let currentChunk = 0;
      const spark = new SparkMD5.ArrayBuffer();
      const fileReader = new FileReader();
      function loadNext() {
        const start = currentChunk * chunkSize;
        const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
      }
      fileReader.onload = e => {
        spark.append(e.target.result); // Append array buffer
        currentChunk += 1;
        if (currentChunk < chunks) {
          loadNext();
        } else {
          console.log('finished loading');
          const result = spark.end();
          // 如果單純的使用result 作為hash值的時候, 如果文件內(nèi)容相同聋溜,而名稱不同的時候
          // 想保留兩個文件無法保留敌呈。所以把文件名稱加上。
          const sparkMd5 = new SparkMD5();
          sparkMd5.append(result);
          sparkMd5.append(file.name);
          const hexHash = sparkMd5.end();
          resolve(hexHash);
        }
      };
      fileReader.onerror = () => {
        console.warn('文件讀取失斄飧浮菩佑!');
      };
      loadNext();
    }).catch(err => {
        console.log(err);
    });
  }
  const submitBtn = $('#submitBtn');
  submitBtn.on('click', async () => {
    const fileDom = $('#file')[0];
    // 獲取到的files為一個File對象數(shù)組自晰,如果允許多選的時候凝化,文件為多個
    const files = fileDom.files;
    const file = files[0];
    if (!file) {
      alert('沒有獲取文件');
      return;
    }
    const blockCount = Math.ceil(file.size / chunkSize); // 分片總數(shù)
    const axiosPromiseArray = []; // axiosPromise數(shù)組
    const hash = await hashFile(file); //文件 hash 
    // 獲取文件hash之后稍坯,如果需要做斷點續(xù)傳,可以根據(jù)hash值去后臺進行校驗。
    // 看看是否已經(jīng)上傳過該文件瞧哟,并且是否已經(jīng)傳送完成以及已經(jīng)上傳的切片混巧。
    console.log(hash);
    
    for (let i = 0; i < blockCount; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      // 構(gòu)建表單
      const form = new FormData();
      form.append('file', blobSlice.call(file, start, end));
      form.append('name', file.name);
      form.append('total', blockCount);
      form.append('index', i);
      form.append('size', file.size);
      form.append('hash', hash);
      // ajax提交 分片,此時 content-type 為 multipart/form-data
      const axiosOptions = {
        onUploadProgress: e => {
          // 處理上傳的進度
          console.log(blockCount, i, e, file);
        },
      };
      // 加入到 Promise 數(shù)組中
      axiosPromiseArray.push(axios.post('/file/upload', form, axiosOptions));
    }
    // 所有分片上傳后勤揩,請求合并分片文件
    await axios.all(axiosPromiseArray).then(() => {
      // 合并chunks
      const data = {
        size: file.size,
        name: file.name,
        total: blockCount,
        hash
      };
      axios.post('/file/merge_chunks', data).then(res => {
        console.log('上傳成功');
        console.log(res.data, file);
        alert('上傳成功');
      }).catch(err => {
        console.log(err);
      });
    });
  });
})

我們需要獲取分片的總數(shù) —— 然后使用 for循環(huán)遍歷分片的總數(shù) —— 然后依次實例化formData數(shù)據(jù) —— 依次把對應的分片添加到 formData數(shù)據(jù)里面去咧党。

然后分別使用 '/file/upload' 請求數(shù)據(jù),最后把所有請求成功的數(shù)據(jù)放入到 axiosPromiseArray 數(shù)組中陨亡,當所有的分片上傳完成后傍衡,我們會使用 await axios.all(axiosPromiseArray).then(() => {}) 方法,最后我們會使用 '/file/merge_chunks' 方法來合并文件负蠕。

const Koa = require('koa');
const app = new Koa();
const Router = require('koa-router');
const multer = require('koa-multer');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs-extra');
const koaBody = require('koa-body');
const { mkdirsSync } = require('./utils/dir');
const uploadPath = path.join(__dirname, 'uploads');
const uploadTempPath = path.join(uploadPath, 'temp');
const upload = multer({ dest: uploadTempPath });
const router = new Router();
app.use(koaBody());
/**
 * single(fieldname)
 * Accept a single file with the name fieldname. The single file will be stored in req.file.
 */
router.post('/file/upload', upload.single('file'), async (ctx, next) => {
  console.log('file upload...')
  // 根據(jù)文件hash創(chuàng)建文件夾蛙埂,把默認上傳的文件移動當前hash文件夾下。方便后續(xù)文件合并遮糖。
  const {
    name,
    total,
    index,
    size,
    hash
  } = ctx.req.body;

  const chunksPath = path.join(uploadPath, hash, '/');
  if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
  fs.renameSync(ctx.req.file.path, chunksPath + hash + '-' + index);
  ctx.status = 200;
  ctx.res.end('Success');
})

router.post('/file/merge_chunks', async (ctx, next) => {
  const {    
    size, 
    name, 
    total, 
    hash
  } = ctx.request.body;
  // 根據(jù)hash值绣的,獲取分片文件。
  // 創(chuàng)建存儲文件
  // 合并
  const chunksPath = path.join(uploadPath, hash, '/');
  const filePath = path.join(uploadPath, name);
  // 讀取所有的chunks 文件名存放在數(shù)組中
  const chunks = fs.readdirSync(chunksPath);
  // 創(chuàng)建存儲文件
  fs.writeFileSync(filePath, ''); 
  if(chunks.length !== total || chunks.length === 0) {
    ctx.status = 200;
    ctx.res.end('切片文件數(shù)量不符合');
    return;
  }
  for (let i = 0; i < total; i++) {
    // 追加寫入到文件中
    fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
    // 刪除本次使用的chunk    
    fs.unlinkSync(chunksPath + hash + '-' +i);
  }
  fs.rmdirSync(chunksPath);
  // 文件合并成功欲账,可以把文件信息進行入庫屡江。
  ctx.status = 200;
  ctx.res.end('合并成功');
})
app.use(router.routes());
app.use(router.allowedMethods());
app.use(serve(__dirname + '/static'));
app.listen(9000, () => {
  console.log('服務9000端口已經(jīng)啟動了');
});

utils/dir.js,該代碼的作用是判斷是否有這個目錄赛不,有這個目錄的話惩嘉,直接返回true,否則的話俄删,創(chuàng)建該目錄

const path = require('path');
const fs = require('fs-extra');
const mkdirsSync = (dirname) => {
  if(fs.existsSync(dirname)) {
    return true;
  } else {
    if (mkdirsSync(path.dirname(dirname))) {
      fs.mkdirSync(dirname);
      return true;
    }
  }
}
module.exports = {
  mkdirsSync
};

我們先看 '/file/upload' 這個請求宏怔,獲取到文件后,請求成功回調(diào)畴椰,然后會在項目中的根目錄下創(chuàng)建一個 uploads 這個目錄

我們也可以在我們的網(wǎng)絡中看到很多 '/file/upload' 的請求臊诊,說明我們的請求是分片上傳的

最后所有的分片請求上傳成功后,我們會調(diào)用 '/file/merge_chunks' 這個請求來合并所有的文件斜脂,根據(jù)我們的hash值抓艳,來獲取文件分片。然后我們會循環(huán)分片的總數(shù)帚戳,然后把所有的分片寫入到我們的filePath目錄中

fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));

其中 filePath 的獲取 是這句代碼:const filePath = path.join(uploadPath, name); 也就是說在我們項目的根目錄下的uploads文件夾下玷或,這么做的原因是為了防止網(wǎng)絡突然斷開或服務器突然異常的情況下,文件上傳到一半的時候片任,我們本地會保存一部分已經(jīng)上傳的文件偏友,如果我們繼續(xù)上傳的時候,我們會跳過哪些已經(jīng)上傳后的文件对供,繼續(xù)上傳未上傳的文件位他。這是為了斷點續(xù)傳做好準備的氛濒,下次我會分析下如何實現(xiàn)斷點續(xù)傳的原理了。

如上就是我們整個分片上傳的基本原理鹅髓,我們還沒有做斷點續(xù)傳了舞竿,下次有空我們來分析下斷點續(xù)傳的基本原理,斷點續(xù)傳的原理窿冯,無非就是說在我們上傳的過程中骗奖,如果網(wǎng)絡中斷或服務器中斷的情況下,我們需要把文件保存到本地醒串,然后當網(wǎng)絡恢復的時候执桌,我們繼續(xù)上傳,那么繼續(xù)上傳的時候芜赌,我們會比較上傳的hash值是否在我本地的hash值是否相同鼻吮,如果相同的話,直接跳過該分片上傳较鼓,繼續(xù)下一個分片上傳椎木,依次類推來進行判斷,雖然使用這種方式來進行比對的情況下博烂,會需要一點時間香椎,但是相對于我們重新上傳消耗的時間來講,這些時間不算什么的禽篱。下次有空我們來分析下斷點續(xù)傳的基本原理哦畜伐。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市躺率,隨后出現(xiàn)的幾起案子玛界,更是在濱河造成了極大的恐慌,老刑警劉巖悼吱,帶你破解...
    沈念sama閱讀 206,482評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件慎框,死亡現(xiàn)場離奇詭異,居然都是意外死亡后添,警方通過查閱死者的電腦和手機笨枯,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,377評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來遇西,“玉大人馅精,你說我怎么就攤上這事×惶矗” “怎么了洲敢?”我有些...
    開封第一講書人閱讀 152,762評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長茄蚯。 經(jīng)常有香客問我压彭,道長称近,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,273評論 1 279
  • 正文 為了忘掉前任哮塞,我火速辦了婚禮,結(jié)果婚禮上凳谦,老公的妹妹穿的比我還像新娘忆畅。我一直安慰自己,他們只是感情好尸执,可當我...
    茶點故事閱讀 64,289評論 5 373
  • 文/花漫 我一把揭開白布家凯。 她就那樣靜靜地躺著,像睡著了一般如失。 火紅的嫁衣襯著肌膚如雪绊诲。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,046評論 1 285
  • 那天褪贵,我揣著相機與錄音掂之,去河邊找鬼。 笑死脆丁,一個胖子當著我的面吹牛世舰,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播槽卫,決...
    沈念sama閱讀 38,351評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼跟压,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了歼培?” 一聲冷哼從身側(cè)響起震蒋,我...
    開封第一講書人閱讀 36,988評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎躲庄,沒想到半個月后查剖,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,476評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡噪窘,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,948評論 2 324
  • 正文 我和宋清朗相戀三年梗搅,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片效览。...
    茶點故事閱讀 38,064評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡无切,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出丐枉,到底是詐尸還是另有隱情哆键,我是刑警寧澤,帶...
    沈念sama閱讀 33,712評論 4 323
  • 正文 年R本政府宣布瘦锹,位于F島的核電站籍嘹,受9級特大地震影響闪盔,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜辱士,卻給世界環(huán)境...
    茶點故事閱讀 39,261評論 3 307
  • 文/蒙蒙 一泪掀、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧颂碘,春花似錦异赫、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,264評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至峡竣,卻和暖如春靠抑,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背适掰。 一陣腳步聲響...
    開封第一講書人閱讀 31,486評論 1 262
  • 我被黑心中介騙來泰國打工颂碧, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人类浪。 一個月前我還...
    沈念sama閱讀 45,511評論 2 354
  • 正文 我出身青樓稚伍,卻偏偏與公主長得像,于是被迫代替她去往敵國和親戚宦。 傳聞我的和親對象是個殘疾皇子个曙,可洞房花燭夜當晚...
    茶點故事閱讀 42,802評論 2 345

推薦閱讀更多精彩內(nèi)容