捣鼓系列:前端大文件上传

某一天,在逛某金的时候忽然看到这篇文章,前端大文件上传,以前也研究过相似的原理,可是一直没能亲手作一次,始终感受有点虚,最近花了点时间,精(熬)心(夜)准(肝)备(爆)了个例子,来和你们分享。html

本文代码:github前端

upload1.gif

问题

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

tools.ietf.org/id/draft-th…mysql

若是一个文件太大,好比音视频数据、下载的excel表格等等,若是在上传的过程当中,等待时间超过30 ~ 120s,服务器没有数据返回,就有可能被认为超时,这是上传的文件就会被中断。jquery

另一个问题是,在大文件上传的过程当中,上传到服务器的数据由于服务器问题或者其余的网络问题致使中断、超时,这是上传的数据将不会被保存,形成上传的浪费。ios

原理

大文件上传利用将大文件分片的原则,将一个大文件拆分红几个小的文件分别上传,而后在小文件上传完成以后,通知服务器进行文件合并,至此完成大文件上传。git

这种方式的上传解决了几个问题:github

  • 文件太大致使的请求超时
  • 将一个请求拆分红多个请求(如今比较流行的浏览器,通常默认的数量是6个,同源请求并发上传的数量),增长并发数,提高了文件传输的速度
  • 小文件的数据便于服务器保存,若是发生网络中断,下次上传时,已经上传的数据能够再也不上传

实现

文件分片

File接口是基于Blob的,所以咱们能够将上传的文件对象使用slice方法 进行分割,具体的实现以下:web

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);
  });
};
复制代码

而后将每一个小的文件,使用表单的方式上传ajax

_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文件被删除,可是在数据库中却存在记录,这样合并出来的文件是有问题的。

文件秒传

upload4.gif

如何作到文件秒传,思考三秒,公布答案,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了解下。创做不易,求⭐️⭐️。

相关文章
相关标签/搜索