文件秒传以及断点续传

上一篇文章中我们已经了解到了前端的文件切片上传以及后端的切片合并的思路及原理,这一次我们来聊聊文件的秒传以及断点续传。过程只有核心代码,完整代码看文末哈。

秒传是如何实现的?

每个文件能通过算法来计算hash值,且相同的文件的hash是一样的。这样我们就能在上传文件之前,调用接口查询数据库是否有相同的hash的文件,如果有则直接返回上传成功。

前端计算hash,一般使用第三方库spark-md5 。安装一下

npm i spark-md5 

核心代码

<template>
  <div v-loading.fullscreen="loading" class="container">
    <input type="file" @change="onFileChange" />
  <el-button type="primary" @click="upload" :loading="loading">上传</el-button>
  </div>
</template>

上一个文章是选项式API的写法,为了熟悉组合式API以及setup语法糖,所以这里更改了写法。在input标签监听change事件,然后在事件会调用计算hash值。FileReader是一个文件读取类,能够以多种方式读取文件,如readAsArrayBuffer readAsDataURL readAsText等等,读取是异步的,读取完成会执行onload回调。

import axios from 'axios';
import SparkMD5 from 'spark-md5'
import { ref } from 'vue';
import { ElMessage } from 'element-plus'

const file_hash = ref('')
const file = ref(null)
const filename = ref("")

const onFileChange = (event) => {
  file.value = event.target.files[0]; //获取文件对象
      filename.value = file.value.name
      const reader = new FileReader(); 
      const sparkk = new SparkMD5.ArrayBuffer() 
      reader.readAsArrayBuffer(file.value);
      reader.onload = (e) => {
        sparkk.append(e.target.result);//直接添加整个文件
        file_hash.value =sparkk.end(); //调用end方法,返回hash
      }
}

这时候我们拿到了文件的hash,文件名和文件对象。这时候我们写一个调接口的方法checkHaveFilecheck接口是为了上传之前查看服务器有无该文件或该文件hash

const checkHaveFile = async () => {
  const res =  await axios.get('http://127.0.0.1:3000/check', {
        params: {
          filename: filename.value,
          file_hash: file_hash.value
        }
      });
      console.log('res',res);      
      return res.data
}

从上面template中可知在点击上传按钮的时候,调用upload

const upload = async() => {
  let continueChunk = 0
  if (!file.value) {
    ElMessage({
      message: '请选择文件',
      type: 'error',
    })
    return;
  }
  const haveFileRes = await checkHaveFile();

  if (haveFileRes.code === 201) {
    ElMessage({
      message: '秒传成功',
      type: 'success',
    })
    return
  } else if (haveFileRes.code === 202) {
    // ElMessage('断点续传');
    continueChunk = haveFileRes.data
  }
  // 剩下的逻辑...
}

上一篇文章说过用nodejs来写接口,因为实现相对简单(其实是只会这个)。

先看一下我的node服务端的目录结构:

文件秒传以及断点续传

写这个check接口。现在写一下后端的check接口。可以看到引入了hashFns

const hashFns = require('./utils/hashFns')

app.get('/check', async(req, res) => {
  const { filename, file_hash } = req.query;
  const hasFile = await hashFns.checkHash(file_hash) //查看服务器有没有该文件
  const hasChunk = await hashFns.hasChunk(file_hash) //查看是否存在切片,断点续传要用
  console.log('hasChunk',hasChunk);
  
  if(hasFile){
    res.send({
      code: 201,
      msg: '秒传成功',
    });
  }else if(hasChunk!==-1){
    res.send({
      code:202,
      msg:'断点续传',
      data:hasChunk
    })
  }else{
    res.send({
      code:200,
      msg:'文件不存在,继续上传',
    })
  }
})
//./utils/hashFns.js
//读取json文件是否存在该hash
const checkHash = (hash, filePath = path.resolve(__dirname, 'db.json')) => {
  return new Promise((resolve, reject) => {
    // 读取JSON文件
    fs.readFile(filePath, 'utf8', (err, data) => {
      if (err) {
        console.error('Error reading file:', err);
        reject()
        return;
      }
      // 解析JSON数据
      let jsonData = JSON.parse(data);
      console.log(Object.keys(jsonData).includes(hash))
      if (jsonData[hash]) {
        console.log('true')
        resolve(true)
      } else {
        console.log('false')
        resolve(false)
      }
    });
  })
}

鉴于主要提供思路及快速实现,我用一个db.json文件模拟数据库

//db.json
{
  "1da8e8fde4d9ab8e4047e8ca5493d420": "d:\server\uploadTest\acceptFiles\22.jpg",
  "868f746a5ebbc15aab4a6185a0b1803b": "d:\server\uploadTest\acceptFiles\33.jpg"
}

可以看到以上checkHash如果返回true,则说明db.json中找到了与前端上传的hash相同的文件,check接口会返回{code:201,msg: '秒传成功'}

尝试一下,选择一个文件上传

文件秒传以及断点续传

服务器已经接收到

文件秒传以及断点续传

再次上传相同文件

文件秒传以及断点续传

断点续传是如何实现的?

其实和秒传的原理差不多,都是通过hash来进行操作的。假如我要上传学习资料.mp4 文件,想要断点续传,那就先切片。假如分了10个切片,在上传了 5 个切片时,网络断了,服务器只收到了 5 个切片。这时候我要想重新上传剩下的切片,服务器需要一个这个文件hash作为标识,就比如我直接用这个hash当作目录名称了,那是不是我直接查找这个目录名为该hash的目录内有多少个切片并且返回浏览器,前端就知道我们要从哪里开始上传了。
同样是先通过check接口先查询,还记得node接口check是不是还调用了hashFns.hasChunk(file_hash),这个就是查询temp目录有无相同hash的目录名存在的。

//./utils/hashFns.js
const hasChunk = (hash) => {
  return new Promise((resolve, reject) => {
    //查看是否存在 temp 文件夹
    try {
      // 检查目录是否存在
      fs.accessSync(path.resolve(__dirname, "../temp"), fs.constants.F_OK);
      console.log('Directory exists.');
    } catch (err) {
      if (err.code === 'ENOENT') {
        console.log('Directory does not exist.');
        fs.mkdirSync(path.resolve(__dirname, '../temp'), { recursive: true });
      } else {
        // 其他错误,如目录不可读取
        console.error('An error occurred:', err);
      }
    }
    //查看temp目录下是否有目录名与hash相同的目录
    fs.readdir(path.resolve(__dirname, "../temp"), (err, files) => {
      if (err) {
        reject()
      }
      console.log("__files", files)
      // resolve(files.indexOf(hash))
      if (files.indexOf(hash) === -1) {
        resolve(-1)
      } else {
        //如果有与该hash相同的文件,则读取该文件夹下的文件数,也就是切片数量
        resolve(new Promise((resolveChunk, rejectChunk) => {
          fs.readdir(path.resolve(__dirname, "../temp", hash), (err, chunkfiles) => {
            if (err) {
              rejectChunk()
            }
            resolveChunk(chunkfiles.length)
          })
        }))
      }
    })
  })
}

如果有则会返回切片数量,check接口会返回{ code:202, msg:'断点续传',data:hasChunk}data就是服务器已存在的切片数量。前端进行切片操作的时候,根据这个数量来设置开始切片的index就行了,当上传完成会调用/upload/complete接口,以下分别是上传和上传完成的接口。

// 很显然,这里使用了个中间件,是存储文件的
app.post('/upload',upload.single('file'),(req, res) => {
  res.send({
    code: 200,
    msg: '上传成功',
  });
});
//当前端所有切片上传完成之后,会调用这个接口进行合并
app.post('/upload/complete', (req, res) => {
  const file_hash = req.body.file_hash;
  const filename = req.body.filename;
  console.log('file_hash',file_hash);
  try {
    combineChunks(ACCEPTDIR,path.resolve(__dirname, 'temp',file_hash),filename)
    .then(() => {
      hashFns.updatedHash(file_hash,path.resolve(ACCEPTDIR, filename) )
      res.json('文件上传完成');
    })
  }catch (error) {
    res.json('文件上传失败', err);
    fs.rm(path.resolve(__dirname,'temp', file_hash), { recursive: true }, (err) => {
      if (err) {
        // 处理错误
        console.error('临时文件夹删除失败',err);
      } else {
        // 删除成功
        console.log('临时文件夹删除成功.');
      }
    } );
    console.log('合并文件时发生错误', error);
  }
});

对于upload接口的中间件以及合并切片操作可以参考上篇文章或者下载完整demo代码(文末)。

我们来试一下,选择文件

文件秒传以及断点续传

文件秒传以及断点续传

点击上传然后快速按 F5 模拟上传中断。可以看到temp文件夹存在了几个切片且目录名是hash

文件秒传以及断点续传

再次选择相同文件,点击上传,check接口返回数据data:6,根据这个数来再次分片上传。

文件秒传以及断点续传

文件秒传以及断点续传

可以看到服务器已经上传成功。这个时候有个问题,上一次上传的切片是删不掉的(因为我已经改了,图片没有展示出来),因为上次上传中断了,mutler这个第三方包并没有关闭文件流,这个时候我们要监听一下aborted事件,在这个事件触发的时候关闭文件流。这样就大功告成了!

const storage = multer.diskStorage({
  // 设置文件存储的位置,其中回调函数的第一个参数表示错误信息,第二个参数表示文件的存储路径
  destination: function (req, file, cb) {
    // 创建临时文件夹
    try {
      fs.mkdirSync(path.resolve(__dirname, 'temp',req.body.fileHash), { recursive: true });
    } catch (error) {
      console.log(' 创建临时文件夹失败',error);
    }
    cb(null, path.resolve(__dirname, 'temp',req.body.fileHash));
  },
  // 设置文件名,同上,第一个参数表示错误信息,第二个参数表示文件名
  filename:async function (req, file, cb) {
    req.on('aborted',(e)=>{
      file.stream.emit('end') //手动关闭流
    })
    await cb(null, file.fieldname + '_' + req.body.chunkIndex);
  }
});

完整demo代码gitee.com/cweile/uplo…

原文链接:https://juejin.cn/post/7327424955036041253 作者:我_恨月亮

(0)
上一篇 2024年1月24日 下午4:41
下一篇 2024年1月24日 下午4:51

相关推荐

发表回复

登录后才能评论