项目实践 | 文件上传/下载

199 阅读5分钟

文件传输和下载

(1)前端请求:正常发送资源获取请求,但要设置 resType: 'blob'

(2)后端返回res.download(文件路径,文件名,(err)=>{})

const loadDataAll = async () => {
    // 服务器请求
    const res = await axios({
      method: 'get',
      url: 'http://localhost:3000/data/getTblAll',
      resType: 'blob'
    });
    const text = await res.data.text();
    // 解析
    dataAll = d3.csvParse(text,d3.autoType);
    await processData(); 	//去筛选和渲染
}
exports.getTblAll = async function (req,res){
    const filePath = path.join( dataPath,'xxx.csv');
    res.download(filePath, 'xxx.csv', (err) => {
        if (err) {
            res.status(500).send('Error downloading file');
        }
    });
}

(3)前端下载

  1. 上述请求文件
  2. 将 blob 解析成 本地 url --- createObjectURL
  3. 创建 a 标签,将 url 赋值给 a.href
  4. 设置 a 标签的 download 属性 + 设置文件名
  5. 模拟点击 a 标签
// 下载方法2:利用a标签
let downloadWithA = () => {
  axios.get("http://localhost:3000/data/getFeatFile?filename="+ downloadFilename.value,{
              responseType: "blob"})
    .then((res) => {
      //2、创建blob对象的本地ur1
      let blobURL = URL.createObjectURL(res.data);
      
      //3、创建标签,设置href
      let link = document.createElement("a")
      link.href = blobURL
      
      //4、设置down1oad
      link.download = downloadFilename.value //自定义下载后的文件名
      link.style.display = "none";
      
      //5、模拟点击
      link.click();
      
      window.URL.revokeObjectURL(blobURL) //移除url 释放内存
  })
}

// 下载方法 1
let downloadFile = () => {
    window.open(
        "http://localhost:5173/public/downloadFile/" + downloadFilename.value,
        '_self'
    );
  }

文件上传(断点续传/秒传)

前端

1 从文件上传表单获取 file 对象

<input type="file" name="file" multiple 
      @change="handleUpload"
/>

<script>
let handleUpload = async (e) => {
  const files = e.target.files  //多文件伪数组
  const file = files[0]
//file.name,file.size
  
</script>

在 el-upload 中

<!-- 
    action:指定上传地址
    http-request:覆盖掉默认上传逻辑 
-->
<el-upload
    action="#" 	
    :http-request="handleUpload">
  
<script>
  let handleUpload = async (options) => {
    const file = options.file
    // ... 
</script>

2 对 file对象进行循环切割,得到一个分片数组

while(cur < file.size) {
  fileChunks.push(file.slice(cur, cur + CHUNK_SIZE))
  cur += CHUNK_SIZE
}

3 对分片执行 hash 计算

根据分片/文件内容计算 hash,用于唯一标识

使用 sparkMD5 计算 hash

策略:如果每个分片都计算 hash 很耗时,因此:

  1. 首尾分片全部计算
  2. 中间分片分别在前后和中间各取2个字节参与计算
  const calculateHash = async (fileChunks) => {
    return new Promise(resolve => {
      //确定参与计算hash的分片
      const unHashedChunks = [] 
      fileChunks.forEach((chunk, index) => {
        if (index === 0 || index === fileChunks.length - 1) {
          // 1.首尾分片
          unHashedChunks.push(chunk)
        } else {
          // 2. 中间分片
          unHashedChunks.push(chunk.slice(0,2))  // 前面 2 字节
          unHashedChunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))  // 中间 2 字节
          unHashedChunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE))   // 后面 2 字节
        }
      })

      // sparkMD5计算hash
      const spark = new SparkMD5.ArrayBuffer();
      const fileReader = new FileReader();
      // 读取文件/分片,追加到 spark 实例
      fileReader.readAsArrayBuffer(new Blob(unHashedChunks));
      fileReader.onload = (event) => {
        spark.append(event?.target?.result);
        resolve(spark.end()); //返回hash值
      };
    })
  }

4 判断是否秒传

文件名+文件hash 发送到服务器,服务返回

  • shouldUpload(是否需要上传) ,用于秒传
  • uploadedChunksHash(目前已上传的分片的hash),用于断点续传。
  //判断是否秒传
  const verifyUpload = async() => {
    return axios.post('http://localhost:3000/file/verifyUpload', {
      fileName: fileName.value,
      fileHash: fileHash.value
    }, {
      headers: {
        'Content-Type': 'application/json',
      }
    })
    .then((res) => res.data) //服务器是否有该文件
  }

5 并行上传发分片

5.1 设置分片信息:文件 hash + 分片 hash + 分片(file 对象)+ 分片大小
5.2 断点续传:过滤掉已经上传的分片(根据uploadedChunksHash)
5.3 构造 formData:为每个待上传的 chunk 构造 formData 对象
5.4 并行 + 循环发送分片:每次同时发 6 个请求 ,即 6 个分片

  • 构建每个分片的请求 task ---> 已完成的删除,未完成的加入
  • 构建一个 taskPool 池,池中任务到一定数量则用 await Promise.race 暂时阻塞
  /**  5.分片上传  */
  const uploadChunks = async(chunks,uploadedChunksHash=[]) => {
      // 5.1 设置分片信息
      const chunksInfo = chunks.map((chunk, index) => {
        return {
          fileHash: fileHash.value, //  区分不同的切片
          chunkHash: fileHash.value + '-' + index, //分片hash值 = hash-index
          chunk: chunk,
          size: chunk.size,
        }
      })
      // 5.3 为每个待上传的 chunk 构造 formData 对象
      const formDatas = chunksInfo
      // 5.2 断点续传:过滤掉已经上传的分片
        .filter((chunk,index) => {
          return !uploadedChunksHash.includes(chunk.chunkHash)
        })
        .map((item) => {
          const formData = new FormData()
          formData.append('chunk', item.chunk)  // 切片文件
          formData.append('chunkHash', item.chunkHash) // 切片文件的 hash
          formData.append('fileName', fileName.value) // 大文件的文件名
          formData.append('fileHash', fileHash.value)  // 大文件的hash
          return formData
        })

      // 5.4 并行+循环发送
      const max = 6 //最大并发请求数
      let index = 0
      const taskPool = [] //请求队列
      // 循环发送分片们
      while(index < formDatas.length){
        const task = fetch('http://localhost:3000/file/uploadFile',{
          method: 'POST',
          body: formDatas[index],
        })
        
        // 已完成的 task 从任务队列里移除;完成的则加入
        task.then(()=>{
          taskPool.splice(taskPool.findIndex((item) => item === task))
        })
        taskPool.push(task)

        // 任务队列里的任务数量到达最大并行数时,阻塞住代码,
        // 等到请求池中至少一个任务完成了,代码才能继续往下执行
        if(taskPool.length === max){
          await Promise.race(taskPool)
        }
        
        index++
      }
    
      // 以防万一,让所有请求在此处都能确保完成
      await Promise.all(taskPool)

      // 通知服务器合并分片
      mergeChunk()
  }

    // 前端发送一个合并切片的请求
    const mergeChunk = () => {
      //发送合并请求
      fetch('http://localhost:3000/file/mergeChunk',{
          method:'POST',
          headers:{
              'Content-Type':'application/json'
          },
          body: JSON.stringify({
              size: CHUNK_SIZE,
              fileHash: fileHash.value,
              fileName: fileName.value,
          })
        })
        .then((res)=> res.json() )
        .then(() => {
          ElMessage({
            message: 'Merge Successfully',
            type: 'success',
          })
        })
      }

后端

1 接受上传的分片

1.1 利用 multiparty 解析 formData

1.2 创建切片们存储的临时文件夹:文件 hash 为名

1.3 把文件切片从默认存放地移动到临时文件夹中

1.4 响应 200 ok

const form = new multiparty.Form()
form.parse(req, async function (err, fields, files) {
  /** Fields: {
    chunkHash: [ '20809a412c59ad1c19e0330d759761d4-1' ],
      fileName: [ 'CHI 可视化 2.pdf' ],
      fileHash: [ '20809a412c59ad1c19e0330d759761d4' ]
  }
  Files: {
    chunk: [
      {
        fieldName: 'chunk',
        originalFilename: 'blob',
        path: '/var/folders/v9/pn7gpmpj51s0brm3hnj4r8rc0000gn/T/ZWwHz6ow8NuzPA089MTOX8YS',
        headers: [Object],
        size: 600084
      }
    ]
   }*/

    // ① 解析 formData
    const chunkHash = fields['chunkHash'][0]
    const fileHash = fields['fileHash'][0]
  
    // ② 创建切片们存储的临时文件夹:文件 hash 为名
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
    if(!fs.existsSync(chunkDir)){  // 切片目录不存在,创建
        await fs.mkdirs(chunkDir)
    }
  
    // ③ 把文件切片移动到自己创建的临时文件夹中
    const oldPath = files.chunk[0].path //系统默认存放地
    await fs.move(oldPath,path.resolve(chunkDir,chunkHash))

    // ④ 
    res.status(200).json({...})
}

2 合并分片

2.1 设置后端文件名:fileNameBack = 文件hash + 后缀

2.2 根据下标对分片们排序

2.3 合并分片:利用 fs 对象的读写流

createReadStream 根据分片路径读取一个分片

createWriteStream 将它拼入最终的 fileNameBack 文件

所有分片读写完毕后,响应 200,删除临时文件夹

// ① 后端文件名:fileNameBack = 文件hash + 后缀
const fileNameBack = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))

// ② 根据下标对分片们排序
chunkHashs.sort((a, b) => {
    return a.split('-')[1] - b.split('-')[1]
})

// ③ 合并分片
// 遍历chunkHashs,对每个分片进行操作:
    const readStream = fs.createReadStream(chunkPath)
    const writeStream = fs.createWriteStream(fileNameBack, {
        start: index * size,
        end: (index + 1) * size
    })
    readStream.on('end', async () => {
        await fs.unlinkSync(chunkPath) //写完后移除该分片
    })
    readStream.pipe(writeStream)
//合并结束,响应 200,删除临时文件夹

3 秒传/断点续传

判断文件是否已经存在,返回 shouldUpload:true/false

断点续传:返回已存在的切片 hash uploadedChunks: [chunk1hash,chunk2hash...]

// 秒传
if (fs.existsSync(fileNameBack)) {
  // 如果文件已存在,秒传成功,无需重复上传
  res.status(200).json({...shouldUpload: false... }
})
else {
   res.status(200).json({...shouldUpload: false,uploadedChunks: chunkHashs... }
}