搗鼓系列:前端大檔案上傳

糊糊糊糊糊了發表於2021-06-28

某一天,在逛某金的時候突然看到這篇文章,前端大檔案上傳,之前也研究過類似的原理,但是一直沒能親手做一次,始終感覺有點虛,最近花了點時間,精(熬)心(夜)準(肝)備(爆)了個例子,來和大家分享。

本文程式碼:github

upload

問題

Knowing the time available to provide a response can avoid problems with timeouts. Current implementations select times between 30 and 120 seconds

https://tools.ietf.org/id/draft-thomson-hybi-http-timeout-00.html

如果一個檔案太大,比如音視訊資料、下載的excel表格等等,如果在上傳的過程中,等待時間超過30 ~ 120s,伺服器沒有資料返回,就有可能被認為超時,這是上傳的檔案就會被中斷。

另外一個問題是,在大檔案上傳的過程中,上傳到伺服器的資料因為伺服器問題或者其他的網路問題導致中斷、超時,這是上傳的資料將不會被儲存,造成上傳的浪費。

原理

大檔案上傳利用將大檔案分片的原則,將一個大檔案拆分成幾個小的檔案分別上傳,然後在小檔案上傳完成之後,通知伺服器進行檔案合併,至此完成大檔案上傳。

這種方式的上傳解決了幾個問題:

  • 檔案太大導致的請求超時
  • 將一個請求拆分成多個請求(現在比較流行的瀏覽器,一般預設的數量是6個,同源請求併發上傳的數量),增加併發數,提升了檔案傳輸的速度
  • 小檔案的資料便於伺服器儲存,如果發生網路中斷,下次上傳時,已經上傳的資料可以不再上傳

實現

檔案分片

File介面是基於Blob的,因此我們可以將上傳的檔案物件使用slice方法 進行分割,具體的實現如下:

export const slice = (file, piece = CHUNK_SIZE) => {
  return new Promise((resolve, reject) => {
    let totalSize = file.size;
    const chunks = [];
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    let start = 0;
    const end = start + piece >= totalSize ? totalSize : start + piece;

    while (start < totalSize) {
        const chunk = blobSlice.call(file, start, end);
        chunks.push(chunk);

        start = end;
        const end = start + piece >= totalSize ? totalSize : start + piece;
    }
    
    resolve(chunks);
  });
};

然後將每個小的檔案,使用表單的方式上傳

_chunkUploadTask(chunks) {
    for (let chunk of chunks) {
        const fd = new FormData();
        fd.append('chunk', chunk);

        return axios({
          url: '/upload',
          method: 'post',
          data: fd,
        })
          .then((res) => res.data)
          .catch((err) => {});
    }
}

後端採用了express,接收檔案採用了[multer](https://github.com/expressjs/multer)這個 庫

multer上傳的的方式有single、array、fields、none、any,做單檔案上傳,採用singlearray皆可,使用比較簡便,通過req.filereq.files來拿到上傳檔案的資訊

另外需要通過disk storage來定製化上傳檔案的檔名,保證在每個上傳的檔案chunk都是唯一的。

const storage = multer.diskStorage({
  destination: uploadTmp,
  filename: (req, file, cb) => {
    // 指定返回的檔名,如果不指定,預設會隨機生成
    cb(null, file.fieldname);
  },
});
const multerUpload = multer({ storage });

// router
router.post('/upload', multerUpload.any(), uploadService.uploadChunk);

// service
uploadChunk: async (req, res) => {
  const file = req.files[0];
  const chunkName = file.filename;

  try {
    const checksum = req.body.checksum;
    const chunkId = req.body.chunkId;

    const message = Messages.success(modules.UPLOAD, actions.UPLOAD, chunkName);
    logger.info(message);
    res.json({ code: 200, message });
  } catch (err) {
    const errMessage = Messages.fail(modules.UPLOAD, actions.UPLOAD, err);
    logger.error(errMessage);
    res.json({ code: 500, message: errMessage });
    res.status(500);
  }
}

上傳的檔案會被儲存在uploads/tmp下,這裡是由multer自動幫我們完成的,成功之後,通過req.files能夠獲取到檔案的資訊,包括chunk的名稱、路徑等等,方便做後續的存庫處理。

為什麼要保證chunk的檔名唯一?

  • 因為檔名是隨機的,代表著一旦發生網路中斷,如果上傳的分片還沒有完成,這時資料庫也不會有相應的存片記錄,導致在下次上傳的時候找不到分片。這樣的後果是,會在tmp目錄下存在著很多遊離的分片,而得不到刪除。
  • 同時在上傳暫停的時候,也能根據chunk的名稱來刪除相應的臨時分片(這步可以不需要,multer判斷分片存在的時候,會自動覆蓋)

如何保證chunk唯一,有兩個辦法,

  • 在做檔案切割的時候,給每個chunk生成檔案指紋 (chunkmd5)
  • 通過整個檔案的檔案指紋,加上chunk的序列號指定(filemd5 + chunkIndex
// 修改上述的程式碼
const chunkName = `${chunkIndex}.${filemd5}.chunk`;
const fd = new FormData();
fd.append(chunkName, chunk);

至此分片上傳就大致完成了。

檔案合併

檔案合併,就是將上傳的檔案分片分別讀取出來,然後整合成一個新的檔案,比較耗IO,可以在一個新的執行緒中去整合。

for (let chunkId = 0; chunkId < chunks; chunkId++) {
  const file = `${uploadTmp}/${chunkId}.${checksum}.chunk`;
  const content = await fsPromises.readFile(file);
  logger.info(Messages.success(modules.UPLOAD, actions.GET, file));
  try {
    await fsPromises.access(path, fs.constants.F_OK);
    await appendFile({ path, content, file, checksum, chunkId });
    if (chunkId === chunks - 1) {
        res.json({ code: 200, message });
    }
  } catch (err) {
    await createFile({ path, content, file, checksum, chunkId });
  }
}

Promise.all(tasks).then(() => {
  // when status in uploading, can send /makefile request
  // if not, when status in canceled, send request will delete chunk which has uploaded.
  if (this.status === fileStatus.UPLOADING) {
    const data = { chunks: this.chunks.length, filename, checksum: this.checksum };
    axios({
      url: '/makefile',
      method: 'post',
      data,
    })
      .then((res) => {
        if (res.data.code === 200) {
          this._setDoneProgress(this.checksum, fileStatus.DONE);
          toastr.success(`file ${filename} upload successfully!`);
        }
      })
      .catch((err) => {
        console.error(err);
        toastr.error(`file ${filename} upload failed!`);
      });
  }
});
  • 首先使用access判斷分片是否存在,如果不存在,則建立新檔案並讀取分片內容
  • 如果chunk檔案存在,則讀取內容到檔案中
  • 每個chunk讀取成功之後,刪除chunk

這裡有幾點需要注意:

  • 如果一個檔案切割出來只有一個chunk,那麼就需要在createFile的時候進行返回,否則請求一直處於pending狀態。

    await createFile({ path, content, file, checksum, chunkId });
    
    if (chunks.length === 1) {
      res.json({ code: 200, message });
    }
    
  • makefile之前務必要判斷檔案是否是上傳狀態,不然在cancel的狀態下,還會繼續上傳,導致chunk上傳之後,chunk檔案被刪除,但是在資料庫中卻存在記錄,這樣合併出來的檔案是有問題的。

檔案秒傳

miao

如何做到檔案秒傳,思考三秒,公佈答案,3. 2. 1.....,其實只是個障眼法。

為啥說是個障眼法,因為根本就沒有傳,檔案是從伺服器來的。這就有幾個問題需要弄清楚,

  • 怎麼確定檔案是伺服器中已經存在了的?
  • 檔案的上傳的資訊是儲存在資料庫中還是客戶端?
  • 檔名不相同,內容相同,應該怎麼處理?

問題一:怎麼判斷檔案已經存在了?

可以為每個檔案上傳生成對應的指紋,但是如果檔案太大,客戶端生成指紋的時間將大大增加,怎麼解決這個問題?

還記得之前的slice,檔案切片麼?大檔案不好做,同樣的思路,切成小檔案,然後計算md5值就好了。這裡使用spark-md5這個庫來生成檔案hash。改造上面的slice方法。

export const checkSum = (file, piece = CHUNK_SIZE) => {
  return new Promise((resolve, reject) => {
    let totalSize = file.size;
    let start = 0;
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    const chunks = [];
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();

    const loadNext = () => {
      const end = start + piece >= totalSize ? totalSize : start + piece;
      const chunk = blobSlice.call(file, start, end);

      start = end;
      chunks.push(chunk);
      fileReader.readAsArrayBuffer(chunk);
    };

    fileReader.onload = (event) => {
      spark.append(event.target.result);

      if (start < totalSize) {
        loadNext();
      } else {
        const checksum = spark.end();
        resolve({ chunks, checksum });
      }
    };

    fileReader.onerror = () => {
      console.warn('oops, something went wrong.');
      reject();
    };

    loadNext();
  });
};

問題二:檔案的上傳的資訊是儲存在資料庫中還是客戶端?

檔案上傳的資訊最好是儲存在服務端的資料庫中(客戶端可以使用IndexDB),這樣做有幾個優點,

  • 資料庫服務提供了成套的CRUD,方便資料的操作
  • 當使用者重新整理瀏覽器之後,或者更換瀏覽器之後,檔案上傳的資訊不會丟失

這裡主要強調的是第二點,因為第一條客戶端也可以做???

const saveFileRecordToDB = async (params) => {
  const { filename, checksum, chunks, isCopy, res } = params;
  await uploadRepository.create({ name: filename, checksum, chunks, isCopy });

  const message = Messages.success(modules.UPLOAD, actions.UPLOAD, filename);
  logger.info(message);
  res.json({ code: 200, message });
};

問題三:檔名不相同,內容相同,應該怎麼處理?

這裡同樣有兩個解決辦法:

  • 檔案copy,直接將檔案複製一份,然後更新資料庫記錄,並且加上isCopy的標識
  • 檔案引用,資料庫儲存記錄,加上isCopylinkTo的標識

這兩種方式有什麼區別:

使用檔案copy的方式,在刪除檔案的時候會更加自由點,因為原始檔案和複製的檔案都是獨立存在的,刪除不會相互干涉,缺點是會存在很多內容相同的檔案;

但是使用引用方式複製的檔案的刪除就比較麻煩,如果刪除的是複製的檔案倒還好,刪除的如果是原始檔案,就必須先將原始檔copy一份到任意的一個複製檔案中同時修改負責的記錄中的isCopyfalse, 然後才能刪除原檔案的資料庫記錄。

這裡做了個圖,順便貼下:

fileCopy

理論上講,檔案引用的方式可能更加好一點,這裡偷了個懶,採用了檔案複製的方式。

// 客戶端
uploadFileInSecond() {
  const id = ID();
  const filename = this.file.name;
  this._renderProgressBar(id);

  const names = this.serverFiles.map((file) => file.name);
  if (names.indexOf(filename) === -1) {
    const sourceFilename = names[0];
    const targetFilename = filename;

    this._setDoneProgress(id, fileStatus.DONE_IN_SECOND);
    axios({
      url: '/copyfile',
      method: 'get',
      params: { targetFilename, sourceFilename, checksum: this.checksum },
    })
      .then((res) => {
        if (res.data.code === 200) {
          toastr.success(`file ${filename} upload successfully!`);
        }
      })
      .catch((err) => {
        console.error(err);
        toastr.error(`file ${filename} upload failed!`);
      });
  } else {
    this._setDoneProgress(id, fileStatus.EXISTED);
    toastr.success(`file ${filename} has existed`);
  }
}

// 伺服器端
copyFile: async (req, res) => {
  const sourceFilename = req.query.sourceFilename;
  const targetFilename = req.query.targetFilename;
  const checksum = req.query.checksum;
  const sourceFile = `${uploadPath}/${sourceFilename}`;
  const targetFile = `${uploadPath}/${targetFilename}`;

  try {
    await fsPromises.copyFile(sourceFile, targetFile);
    await saveFileRecordToDB({ filename: targetFilename, checksum, chunks: 0, isCopy: true, res });
  } catch (err) {
    const message = Messages.fail(modules.UPLOAD, actions.UPLOAD, err.message);
    logger.info(message);
    res.json({ code: 500, message });
    res.status(500);
  }
}

檔案上傳暫停與檔案續傳

檔案上傳暫停,其實是利用了xhrabort方法,因為在案例中採用的是axiosaxios基於ajax封裝了自己的實現方式。

這裡看看程式碼暫停程式碼:

const CancelToken = axios.CancelToken;

axios({
  url: '/upload',
  method: 'post',
  data: fd,
  cancelToken: new CancelToken((c) => {
    // An executor function receives a cancel function as a parameter
    canceler = c;
    this.cancelers.push(canceler);
  }),
})

axios在每個請求中使用了一個引數cancelToken,這個cancelToken是一個函式,可以利用這個函式來儲存每個請求的cancel控制程式碼。

然後在點選取消的時候,取消每個chunk的上傳,如下:

// 這裡使用了jquery來編寫html,好吧,確實寫?了

$(`#cancel${id}`).on('click', (event) => {
  const $this = $(event.target);
  $this.addClass('hidden');
  $this.next('.resume').removeClass('hidden');

  this.status = fileStatus.CANCELED;
  if (this.cancelers.length > 0) {
    for (const canceler of this.cancelers) {
      canceler();
    }
  }
});

在每個chunk上傳的同時,我們也需要判斷每個chunk是否存在?為什麼?

因為發生意外的網路中斷,上傳到chunk資訊就會被儲存到資料庫中,所以在做續傳的時候,已經存在的chunk就可以不用再傳了,節省了時間。

那麼問題來了,是每個chunk單一檢測,還是預先檢測伺服器中已經存在的chunks?

這個問題也可以思考三秒,畢竟debug了好久。

3.. 2.. 1......

看個人的程式碼策略,因為畢竟每個人寫程式碼的方式不同。原則是,不能阻塞每次的迴圈,因為在迴圈中需要生成每個chunk的cancelToken,如果在迴圈中,每個chunk都要從伺服器中拿一遍資料,會導致後續的chunk生成不了cancelToken,這樣在點選了cancel的時候,後續的chunk還是能夠繼續上傳。

// 客戶端
const chunksExisted = await this._isChunksExists();

for (let chunkId = 0; chunkId < this.chunks.length; chunkId++) {
  const chunk = this.chunks[chunkId];
  // 很早之前的程式碼是這樣的
  // 這裡會阻塞cancelToken的生成
  // const chunkExists = await isChunkExisted(this.checksum, chunkId);

  const chunkExists = chunksExisted[chunkId];

  if (!chunkExists) {
    const task = this._chunkUploadTask({ chunk, chunkId });
    tasks.push(task);
  } else {
    // if chunk is existed, need to set the with of chunk progress bar
    this._setUploadingChunkProgress(this.checksum, chunkId, 100);
    this.progresses[chunkId] = chunk.size;
  }
}

// 伺服器端
chunksExist: async (req, res) => {
  const checksum = req.query.checksum;
  try {
    const chunks = await chunkRepository.findAllBy({ checksum });
    const exists = chunks.reduce((cur, chunk) => {
      cur[chunk.chunkId] = true;
      return cur;
    }, {});
    const message = Messages.success(modules.UPLOAD, actions.CHECK, `chunk ${JSON.stringify(exists)} exists`);
    logger.info(message);
    res.json({ code: 200, message: message, data: exists });
  } catch (err) {
    const errMessage = Messages.fail(modules.UPLOAD, actions.CHECK, err);
    logger.error(errMessage);
    res.json({ code: 500, message: errMessage });
    res.status(500);
  }
}

檔案續傳就是重新上傳檔案,這點沒有什麼可以講的,主要是要把上面的那個問題解決了。

$(`#resume${id}`).on('click', async (event) => {
  const $this = $(event.target);
  $this.addClass('hidden');
  $this.prev('.cancel').removeClass('hidden');

  this.status = fileStatus.UPLOADING;
  await this.uploadFile();
});

進度回傳

進度回傳是利用了XMLHttpRequest.uploadaxios同樣封裝了相應的方法,這裡需要顯示兩個進度

  • 每個chunk的進度
  • 所有chunk的總進度

每個chunk的進度會根據上傳的loadedtotal來進行計算,這裡也沒有什麼好說的。

axios({
  url: '/upload',
  method: 'post',
  data: fd,
  onUploadProgress: (progressEvent) => {
    const loaded = progressEvent.loaded;
    const chunkPercent = ((loaded / progressEvent.total) * 100).toFixed(0);

    this._setUploadingChunkProgress(this.checksum, chunkId, chunkPercent);
  },
})

總進度則是根據每個chunk的載入量,進行累加,然後在和file.size來進行計算。

constructor(checksum, chunks, file) {
  this.progresses = Array(this.chunks.length).fill(0);
}

axios({
  url: '/upload',
  method: 'post',
  data: fd,
  onUploadProgress: (progressEvent) => {
    const chunkProgress = this.progresses[chunkId];
    const loaded = progressEvent.loaded;
    this.progresses[chunkId] = loaded >= chunkProgress ? loaded : chunkProgress;
    const percent = ((this._getCurrentLoaded(this.progresses) / this.file.size) * 100).toFixed(0);

    this._setUploadingProgress(this.checksum, percent);
  },
})

_setUploadingProgress(id, percent) {
  // ...

  // for some reason, progressEvent.loaded bytes will greater than file size
  const isUploadChunkDone = Number(percent) >= 100;
  // 1% to make file
  const ratio = isUploadChunkDone ? 99 : percent;
}

這裡需要注意的一點是,loaded >= chunkProgress ? loaded : chunkProgress,這樣判斷的目的是,因為續傳的過程中,有可能某些片需要重新重**0**開始上傳,如果不這樣判斷,就會導致進度條的跳動。

資料庫配置

資料庫採用了sequelize + mysql,初始化程式碼如下:

const initialize = async () => {
  // create db if it doesn't already exist
  const { DATABASE, USER, PASSWORD, HOST } = config;
  const connection = await mysql.createConnection({ host: HOST, user: USER, password: PASSWORD });
  try {
    await connection.query(`CREATE DATABASE IF NOT EXISTS ${DATABASE};`);
  } catch (err) {
    logger.error(Messages.fail(modules.DB, actions.CONNECT, `create database ${DATABASE}`));
    throw err;
  }

  // connect to db
  const sequelize = new Sequelize(DATABASE, USER, PASSWORD, {
    host: HOST,
    dialect: 'mysql',
    logging: (msg) => logger.info(Messages.info(modules.DB, actions.CONNECT, msg)),
  });

  // init models and add them to the exported db object
  db.Upload = require('./models/upload')(sequelize);
  db.Chunk = require('./models/chunk')(sequelize);

  // sync all models with database
  await sequelize.sync({ alter: true });
};

部署

生產環境的部署採用了docker-compose,程式碼如下:

Dockerfile

FROM node:16-alpine3.11

# Create app directory
WORKDIR /usr/src/app

# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./

# If you are building your code for production
# RUN npm ci --only=production

# Bundle app source
COPY . .

# Install app dependencies
RUN npm install
RUN npm run build:prod

docker-compose.yml

version: "3.9"
services:
  web:
    build: .
    # sleep for 20 sec, wait for database server start
    command: sh -c "sleep 20 && npm start"
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: prod
    depends_on:
      - db
  db:
    image: mysql:8
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: pwd123

有一點需要注意的是,需要等資料庫服務啟動,然後再啟動web服務,不然會報錯,所以程式碼中加了20秒的延遲。

部署到heroku

  1. create heroku.yml

    build:
      docker:
        web: Dockerfile
    run:
      web: npm run start:heroku
    
  2. modify package.json

    {
      "scripts": {
        "start:heroku": "NODE_ENV=heroku node ./bin/www"
      }
    }
    
  3. deploy to heroku

    # create heroku repos
    heroku create upload-demos
    heroku stack:set container 
    
    # when add addons, remind to config you billing card in heroku [important]
    # add mysql addons
    heroku addons:create cleardb:ignite 
    # get mysql connection url
    heroku config | grep CLEARDB_DATABASE_URL
    # will echo => DATABASE_URL: mysql://xxxxxxx:xxxxxx@xx-xxxx-east-xx.cleardb.com/heroku_9ab10c66a98486e?reconnect=true
    
    # set mysql database url
    heroku config:set DATABASE_URL='mysql://xxxxxxx:xxxxxx@xx-xxxx-east-xx.cleardb.com/heroku_9ab10c66a98486e?reconnect=true'
    
    # add heroku.js to src/db/config folder
    # use the DATABASE_URL which you get form prev step to config the js file
    module.exports = {
      HOST: 'xx-xxxx-east-xx.cleardb.com',
      USER: 'xxxxxxx',
      PASSWORD: 'xxxxxx',
      DATABASE: 'heroku_9ab10c66a98486e',
    };
    
    # push source code to remote
    git push heroku master
    

小結

至此所有的問題都已經解決了,總體的一個感受是處理的細節非常多,有些事情還是不能只是看看,花時間做出來才更加了解原理,更加有動力去學新的知識。

紙上得來終覺淺,絕知此事要躬行。

在程式碼倉庫github還有很多細節,包括本地伺服器開發配置、日誌儲存等等,感興趣的可以自己fork瞭解下。創作不易,求⭐️⭐️。

相關文章