经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » JS/JS库/框架 » JavaScript » 查看文章
捣鼓系列:前端大文件上传
来源:cnblogs  作者:糊糊糊糊糊了  时间:2021/6/28 11:31:52  对本文有异议

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

本文代码: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方法 进行分割,具体的实现如下:

  1. export const slice = (file, piece = CHUNK_SIZE) => {
  2. return new Promise((resolve, reject) => {
  3. let totalSize = file.size;
  4. const chunks = [];
  5. const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
  6. let start = 0;
  7. const end = start + piece >= totalSize ? totalSize : start + piece;
  8. while (start < totalSize) {
  9. const chunk = blobSlice.call(file, start, end);
  10. chunks.push(chunk);
  11. start = end;
  12. const end = start + piece >= totalSize ? totalSize : start + piece;
  13. }
  14. resolve(chunks);
  15. });
  16. };

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

  1. _chunkUploadTask(chunks) {
  2. for (let chunk of chunks) {
  3. const fd = new FormData();
  4. fd.append('chunk', chunk);
  5. return axios({
  6. url: '/upload',
  7. method: 'post',
  8. data: fd,
  9. })
  10. .then((res) => res.data)
  11. .catch((err) => {});
  12. }
  13. }

后端采用了express,接收文件采用了[multer](https://github.com/expressjs/multer)这个 库

multer上传的的方式有single、array、fields、none、any,做单文件上传,采用singlearray皆可,使用比较简便,通过req.filereq.files来拿到上传文件的信息

另外需要通过disk storage来定制化上传文件的文件名,保证在每个上传的文件chunk都是唯一的。

  1. const storage = multer.diskStorage({
  2. destination: uploadTmp,
  3. filename: (req, file, cb) => {
  4. // 指定返回的文件名,如果不指定,默认会随机生成
  5. cb(null, file.fieldname);
  6. },
  7. });
  8. const multerUpload = multer({ storage });
  9. // router
  10. router.post('/upload', multerUpload.any(), uploadService.uploadChunk);
  11. // service
  12. uploadChunk: async (req, res) => {
  13. const file = req.files[0];
  14. const chunkName = file.filename;
  15. try {
  16. const checksum = req.body.checksum;
  17. const chunkId = req.body.chunkId;
  18. const message = Messages.success(modules.UPLOAD, actions.UPLOAD, chunkName);
  19. logger.info(message);
  20. res.json({ code: 200, message });
  21. } catch (err) {
  22. const errMessage = Messages.fail(modules.UPLOAD, actions.UPLOAD, err);
  23. logger.error(errMessage);
  24. res.json({ code: 500, message: errMessage });
  25. res.status(500);
  26. }
  27. }

上传的文件会被保存在uploads/tmp下,这里是由multer自动帮我们完成的,成功之后,通过req.files能够获取到文件的信息,包括chunk的名称、路径等等,方便做后续的存库处理。

为什么要保证chunk的文件名唯一?

  • 因为文件名是随机的,代表着一旦发生网络中断,如果上传的分片还没有完成,这时数据库也不会有相应的存片记录,导致在下次上传的时候找不到分片。这样的后果是,会在tmp目录下存在着很多游离的分片,而得不到删除。
  • 同时在上传暂停的时候,也能根据chunk的名称来删除相应的临时分片(这步可以不需要,multer判断分片存在的时候,会自动覆盖)

如何保证chunk唯一,有两个办法,

  • 在做文件切割的时候,给每个chunk生成文件指纹 (chunkmd5)
  • 通过整个文件的文件指纹,加上chunk的序列号指定(filemd5 + chunkIndex
  1. // 修改上述的代码
  2. const chunkName = `${chunkIndex}.${filemd5}.chunk`;
  3. const fd = new FormData();
  4. fd.append(chunkName, chunk);

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

文件合并

文件合并,就是将上传的文件分片分别读取出来,然后整合成一个新的文件,比较耗IO,可以在一个新的线程中去整合。

  1. for (let chunkId = 0; chunkId < chunks; chunkId++) {
  2. const file = `${uploadTmp}/${chunkId}.${checksum}.chunk`;
  3. const content = await fsPromises.readFile(file);
  4. logger.info(Messages.success(modules.UPLOAD, actions.GET, file));
  5. try {
  6. await fsPromises.access(path, fs.constants.F_OK);
  7. await appendFile({ path, content, file, checksum, chunkId });
  8. if (chunkId === chunks - 1) {
  9. res.json({ code: 200, message });
  10. }
  11. } catch (err) {
  12. await createFile({ path, content, file, checksum, chunkId });
  13. }
  14. }
  15. Promise.all(tasks).then(() => {
  16. // when status in uploading, can send /makefile request
  17. // if not, when status in canceled, send request will delete chunk which has uploaded.
  18. if (this.status === fileStatus.UPLOADING) {
  19. const data = { chunks: this.chunks.length, filename, checksum: this.checksum };
  20. axios({
  21. url: '/makefile',
  22. method: 'post',
  23. data,
  24. })
  25. .then((res) => {
  26. if (res.data.code === 200) {
  27. this._setDoneProgress(this.checksum, fileStatus.DONE);
  28. toastr.success(`file ${filename} upload successfully!`);
  29. }
  30. })
  31. .catch((err) => {
  32. console.error(err);
  33. toastr.error(`file ${filename} upload failed!`);
  34. });
  35. }
  36. });
  • 首先使用access判断分片是否存在,如果不存在,则创建新文件并读取分片内容
  • 如果chunk文件存在,则读取内容到文件中
  • 每个chunk读取成功之后,删除chunk

这里有几点需要注意:

  • 如果一个文件切割出来只有一个chunk,那么就需要在createFile的时候进行返回,否则请求一直处于pending状态。

    1. await createFile({ path, content, file, checksum, chunkId });
    2. if (chunks.length === 1) {
    3. res.json({ code: 200, message });
    4. }
  • makefile之前务必要判断文件是否是上传状态,不然在cancel的状态下,还会继续上传,导致chunk上传之后,chunk文件被删除,但是在数据库中却存在记录,这样合并出来的文件是有问题的。

文件秒传

miao

如何做到文件秒传,思考三秒,公布答案,3. 2. 1.....,其实只是个障眼法。

为啥说是个障眼法,因为根本就没有传,文件是从服务器来的。这就有几个问题需要弄清楚,

  • 怎么确定文件是服务器中已经存在了的?
  • 文件的上传的信息是保存在数据库中还是客户端?
  • 文件名不相同,内容相同,应该怎么处理?

问题一:怎么判断文件已经存在了?

可以为每个文件上传生成对应的指纹,但是如果文件太大,客户端生成指纹的时间将大大增加,怎么解决这个问题?

还记得之前的slice,文件切片么?大文件不好做,同样的思路,切成小文件,然后计算md5值就好了。这里使用spark-md5这个库来生成文件hash。改造上面的slice方法。

  1. export const checkSum = (file, piece = CHUNK_SIZE) => {
  2. return new Promise((resolve, reject) => {
  3. let totalSize = file.size;
  4. let start = 0;
  5. const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
  6. const chunks = [];
  7. const spark = new SparkMD5.ArrayBuffer();
  8. const fileReader = new FileReader();
  9. const loadNext = () => {
  10. const end = start + piece >= totalSize ? totalSize : start + piece;
  11. const chunk = blobSlice.call(file, start, end);
  12. start = end;
  13. chunks.push(chunk);
  14. fileReader.readAsArrayBuffer(chunk);
  15. };
  16. fileReader.onload = (event) => {
  17. spark.append(event.target.result);
  18. if (start < totalSize) {
  19. loadNext();
  20. } else {
  21. const checksum = spark.end();
  22. resolve({ chunks, checksum });
  23. }
  24. };
  25. fileReader.onerror = () => {
  26. console.warn('oops, something went wrong.');
  27. reject();
  28. };
  29. loadNext();
  30. });
  31. };

问题二:文件的上传的信息是保存在数据库中还是客户端?

文件上传的信息最好是保存在服务端的数据库中(客户端可以使用IndexDB),这样做有几个优点,

  • 数据库服务提供了成套的CRUD,方便数据的操作
  • 当用户刷新浏览器之后,或者更换浏览器之后,文件上传的信息不会丢失

这里主要强调的是第二点,因为第一条客户端也可以做??????

  1. const saveFileRecordToDB = async (params) => {
  2. const { filename, checksum, chunks, isCopy, res } = params;
  3. await uploadRepository.create({ name: filename, checksum, chunks, isCopy });
  4. const message = Messages.success(modules.UPLOAD, actions.UPLOAD, filename);
  5. logger.info(message);
  6. res.json({ code: 200, message });
  7. };

问题三:文件名不相同,内容相同,应该怎么处理?

这里同样有两个解决办法:

  • 文件copy,直接将文件复制一份,然后更新数据库记录,并且加上isCopy的标识
  • 文件引用,数据库保存记录,加上isCopylinkTo的标识

这两种方式有什么区别:

使用文件copy的方式,在删除文件的时候会更加自由点,因为原始文件和复制的文件都是独立存在的,删除不会相互干涉,缺点是会存在很多内容相同的文件;

但是使用引用方式复制的文件的删除就比较麻烦,如果删除的是复制的文件倒还好,删除的如果是原始文件,就必须先将源文件copy一份到任意的一个复制文件中同时修改负责的记录中的isCopyfalse, 然后才能删除原文件的数据库记录。

这里做了个图,顺便贴下:

fileCopy

理论上讲,文件引用的方式可能更加好一点,这里偷了个懒,采用了文件复制的方式。

  1. // 客户端
  2. uploadFileInSecond() {
  3. const id = ID();
  4. const filename = this.file.name;
  5. this._renderProgressBar(id);
  6. const names = this.serverFiles.map((file) => file.name);
  7. if (names.indexOf(filename) === -1) {
  8. const sourceFilename = names[0];
  9. const targetFilename = filename;
  10. this._setDoneProgress(id, fileStatus.DONE_IN_SECOND);
  11. axios({
  12. url: '/copyfile',
  13. method: 'get',
  14. params: { targetFilename, sourceFilename, checksum: this.checksum },
  15. })
  16. .then((res) => {
  17. if (res.data.code === 200) {
  18. toastr.success(`file ${filename} upload successfully!`);
  19. }
  20. })
  21. .catch((err) => {
  22. console.error(err);
  23. toastr.error(`file ${filename} upload failed!`);
  24. });
  25. } else {
  26. this._setDoneProgress(id, fileStatus.EXISTED);
  27. toastr.success(`file ${filename} has existed`);
  28. }
  29. }
  30. // 服务器端
  31. copyFile: async (req, res) => {
  32. const sourceFilename = req.query.sourceFilename;
  33. const targetFilename = req.query.targetFilename;
  34. const checksum = req.query.checksum;
  35. const sourceFile = `${uploadPath}/${sourceFilename}`;
  36. const targetFile = `${uploadPath}/${targetFilename}`;
  37. try {
  38. await fsPromises.copyFile(sourceFile, targetFile);
  39. await saveFileRecordToDB({ filename: targetFilename, checksum, chunks: 0, isCopy: true, res });
  40. } catch (err) {
  41. const message = Messages.fail(modules.UPLOAD, actions.UPLOAD, err.message);
  42. logger.info(message);
  43. res.json({ code: 500, message });
  44. res.status(500);
  45. }
  46. }

文件上传暂停与文件续传

文件上传暂停,其实是利用了xhrabort方法,因为在案例中采用的是axiosaxios基于ajax封装了自己的实现方式。

这里看看代码暂停代码:

  1. const CancelToken = axios.CancelToken;
  2. axios({
  3. url: '/upload',
  4. method: 'post',
  5. data: fd,
  6. cancelToken: new CancelToken((c) => {
  7. // An executor function receives a cancel function as a parameter
  8. canceler = c;
  9. this.cancelers.push(canceler);
  10. }),
  11. })

axios在每个请求中使用了一个参数cancelToken,这个cancelToken是一个函数,可以利用这个函数来保存每个请求的cancel句柄。

然后在点击取消的时候,取消每个chunk的上传,如下:

  1. // 这里使用了jquery来编写html,好吧,确实写??了
  2. $(`#cancel${id}`).on('click', (event) => {
  3. const $this = $(event.target);
  4. $this.addClass('hidden');
  5. $this.next('.resume').removeClass('hidden');
  6. this.status = fileStatus.CANCELED;
  7. if (this.cancelers.length > 0) {
  8. for (const canceler of this.cancelers) {
  9. canceler();
  10. }
  11. }
  12. });

在每个chunk上传的同时,我们也需要判断每个chunk是否存在?为什么?

因为发生意外的网络中断,上传到chunk信息就会被保存到数据库中,所以在做续传的时候,已经存在的chunk就可以不用再传了,节省了时间。

那么问题来了,是每个chunk单一检测,还是预先检测服务器中已经存在的chunks?

这个问题也可以思考三秒,毕竟debug了好久。

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

看个人的代码策略,因为毕竟每个人写代码的方式不同。原则是,不能阻塞每次的循环,因为在循环中需要生成每个chunk的cancelToken,如果在循环中,每个chunk都要从服务器中拿一遍数据,会导致后续的chunk生成不了cancelToken,这样在点击了cancel的时候,后续的chunk还是能够继续上传。

  1. // 客户端
  2. const chunksExisted = await this._isChunksExists();
  3. for (let chunkId = 0; chunkId < this.chunks.length; chunkId++) {
  4. const chunk = this.chunks[chunkId];
  5. // 很早之前的代码是这样的
  6. // 这里会阻塞cancelToken的生成
  7. // const chunkExists = await isChunkExisted(this.checksum, chunkId);
  8. const chunkExists = chunksExisted[chunkId];
  9. if (!chunkExists) {
  10. const task = this._chunkUploadTask({ chunk, chunkId });
  11. tasks.push(task);
  12. } else {
  13. // if chunk is existed, need to set the with of chunk progress bar
  14. this._setUploadingChunkProgress(this.checksum, chunkId, 100);
  15. this.progresses[chunkId] = chunk.size;
  16. }
  17. }
  18. // 服务器端
  19. chunksExist: async (req, res) => {
  20. const checksum = req.query.checksum;
  21. try {
  22. const chunks = await chunkRepository.findAllBy({ checksum });
  23. const exists = chunks.reduce((cur, chunk) => {
  24. cur[chunk.chunkId] = true;
  25. return cur;
  26. }, {});
  27. const message = Messages.success(modules.UPLOAD, actions.CHECK, `chunk ${JSON.stringify(exists)} exists`);
  28. logger.info(message);
  29. res.json({ code: 200, message: message, data: exists });
  30. } catch (err) {
  31. const errMessage = Messages.fail(modules.UPLOAD, actions.CHECK, err);
  32. logger.error(errMessage);
  33. res.json({ code: 500, message: errMessage });
  34. res.status(500);
  35. }
  36. }

文件续传就是重新上传文件,这点没有什么可以讲的,主要是要把上面的那个问题解决了。

  1. $(`#resume${id}`).on('click', async (event) => {
  2. const $this = $(event.target);
  3. $this.addClass('hidden');
  4. $this.prev('.cancel').removeClass('hidden');
  5. this.status = fileStatus.UPLOADING;
  6. await this.uploadFile();
  7. });

进度回传

进度回传是利用了XMLHttpRequest.uploadaxios同样封装了相应的方法,这里需要显示两个进度

  • 每个chunk的进度
  • 所有chunk的总进度

每个chunk的进度会根据上传的loadedtotal来进行计算,这里也没有什么好说的。

  1. axios({
  2. url: '/upload',
  3. method: 'post',
  4. data: fd,
  5. onUploadProgress: (progressEvent) => {
  6. const loaded = progressEvent.loaded;
  7. const chunkPercent = ((loaded / progressEvent.total) * 100).toFixed(0);
  8. this._setUploadingChunkProgress(this.checksum, chunkId, chunkPercent);
  9. },
  10. })

总进度则是根据每个chunk的加载量,进行累加,然后在和file.size来进行计算。

  1. constructor(checksum, chunks, file) {
  2. this.progresses = Array(this.chunks.length).fill(0);
  3. }
  4. axios({
  5. url: '/upload',
  6. method: 'post',
  7. data: fd,
  8. onUploadProgress: (progressEvent) => {
  9. const chunkProgress = this.progresses[chunkId];
  10. const loaded = progressEvent.loaded;
  11. this.progresses[chunkId] = loaded >= chunkProgress ? loaded : chunkProgress;
  12. const percent = ((this._getCurrentLoaded(this.progresses) / this.file.size) * 100).toFixed(0);
  13. this._setUploadingProgress(this.checksum, percent);
  14. },
  15. })
  16. _setUploadingProgress(id, percent) {
  17. // ...
  18. // for some reason, progressEvent.loaded bytes will greater than file size
  19. const isUploadChunkDone = Number(percent) >= 100;
  20. // 1% to make file
  21. const ratio = isUploadChunkDone ? 99 : percent;
  22. }

这里需要注意的一点是,loaded >= chunkProgress ? loaded : chunkProgress,这样判断的目的是,因为续传的过程中,有可能某些片需要重新重**0**开始上传,如果不这样判断,就会导致进度条的跳动。

数据库配置

数据库采用了sequelize + mysql,初始化代码如下:

  1. const initialize = async () => {
  2. // create db if it doesn't already exist
  3. const { DATABASE, USER, PASSWORD, HOST } = config;
  4. const connection = await mysql.createConnection({ host: HOST, user: USER, password: PASSWORD });
  5. try {
  6. await connection.query(`CREATE DATABASE IF NOT EXISTS ${DATABASE};`);
  7. } catch (err) {
  8. logger.error(Messages.fail(modules.DB, actions.CONNECT, `create database ${DATABASE}`));
  9. throw err;
  10. }
  11. // connect to db
  12. const sequelize = new Sequelize(DATABASE, USER, PASSWORD, {
  13. host: HOST,
  14. dialect: 'mysql',
  15. logging: (msg) => logger.info(Messages.info(modules.DB, actions.CONNECT, msg)),
  16. });
  17. // init models and add them to the exported db object
  18. db.Upload = require('./models/upload')(sequelize);
  19. db.Chunk = require('./models/chunk')(sequelize);
  20. // sync all models with database
  21. await sequelize.sync({ alter: true });
  22. };

部署

生产环境的部署采用了docker-compose,代码如下:

Dockerfile

  1. FROM node:16-alpine3.11
  2. # Create app directory
  3. WORKDIR /usr/src/app
  4. # A wildcard is used to ensure both package.json AND package-lock.json are copied
  5. # where available (npm@5+)
  6. COPY package*.json ./
  7. # If you are building your code for production
  8. # RUN npm ci --only=production
  9. # Bundle app source
  10. COPY . .
  11. # Install app dependencies
  12. RUN npm install
  13. RUN npm run build:prod

docker-compose.yml

  1. version: "3.9"
  2. services:
  3. web:
  4. build: .
  5. # sleep for 20 sec, wait for database server start
  6. command: sh -c "sleep 20 && npm start"
  7. ports:
  8. - "3000:3000"
  9. environment:
  10. NODE_ENV: prod
  11. depends_on:
  12. - db
  13. db:
  14. image: mysql:8
  15. command: --default-authentication-plugin=mysql_native_password
  16. restart: always
  17. ports:
  18. - "3306:3306"
  19. environment:
  20. MYSQL_ROOT_PASSWORD: pwd123

有一点需要注意的是,需要等数据库服务启动,然后再启动web服务,不然会报错,所以代码中加了20秒的延迟。

部署到heroku

  1. create heroku.yml

    1. build:
    2. docker:
    3. web: Dockerfile
    4. run:
    5. web: npm run start:heroku
  2. modify package.json

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

    1. # create heroku repos
    2. heroku create upload-demos
    3. heroku stack:set container
    4. # when add addons, remind to config you billing card in heroku [important]
    5. # add mysql addons
    6. heroku addons:create cleardb:ignite
    7. # get mysql connection url
    8. heroku config | grep CLEARDB_DATABASE_URL
    9. # will echo => DATABASE_URL: mysql://xxxxxxx:xxxxxx@xx-xxxx-east-xx.cleardb.com/heroku_9ab10c66a98486e?reconnect=true
    10. # set mysql database url
    11. heroku config:set DATABASE_URL='mysql://xxxxxxx:xxxxxx@xx-xxxx-east-xx.cleardb.com/heroku_9ab10c66a98486e?reconnect=true'
    12. # add heroku.js to src/db/config folder
    13. # use the DATABASE_URL which you get form prev step to config the js file
    14. module.exports = {
    15. HOST: 'xx-xxxx-east-xx.cleardb.com',
    16. USER: 'xxxxxxx',
    17. PASSWORD: 'xxxxxx',
    18. DATABASE: 'heroku_9ab10c66a98486e',
    19. };
    20. # push source code to remote
    21. git push heroku master

小结

至此所有的问题都已经解决了,总体的一个感受是处理的细节非常多,有些事情还是不能只是看看,花时间做出来才更加了解原理,更加有动力去学新的知识。

纸上得来终觉浅,绝知此事要躬行。

在代码仓库github还有很多细节,包括本地服务器开发配置、日志存储等等,感兴趣的可以自己fork了解下。创作不易,求????。

原文链接:http://www.cnblogs.com/rynxiao/p/14943078.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号