大文件上传

1. 分片上传前言

  • 分片上传解决的问题
  1. 上传时间比较久
  2. 中间一旦出错就需要重新上传
  3. 一般服务端会对文件的大小进行限制
  4. 秒传
  • 原理

分片上传的原理就像是把一个大的视频文件切成多个小视频片段。每个小块大小相同,比如每块大小为2MB(最好不要大于5MB)。然后,逐个上传这些小块到服务器。上传的时候,可以同时上传多个小块,也可以一个一个地上传。上传每个小块后,服务器会保存这些小块,并记录它们的顺序和位置信息。所有小块上传完成后,服务器会把这些小块按照正确的顺序拼接起来,还原成完整的大文件。

在这里插入图片描述

2. 文件分片

分片的核心就是用Blob对象的slice方法,在上传时获取的文件是一个File对象,它是继承于Blob,所以可以用slice方法对文件进行分片

let blob = instanceOfBlob.slice([start [, end [, contentType]]]};
  1. startend 代表 Blob 里的下标,表示被拷贝进新的 Blob 的字节的起始位置和结束位置。
const CHUNK_SIZE = 1024 * 1024
const createFileChunks = (file: File) => {
  let cur = 0
  const chunks = []
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + CHUNK_SIZE))
    cur += CHUNK_SIZE
  }
  return chunks
}

2.1 文件分片hash计算

  1. 在切片后必须去区分不同的文件,这时可以根据文件内容生产一个唯一的 hash 值,文件内容变化,hash 值就会跟着发生变化。
  2. 使用工具
 npm i spark-md5
 // 配置TS declare module 'spark-md5'
/**利用hash 区分文件
 * 1. 第一个和最后一个切片的内容全部参与计算
 * 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
 * 文件秒传原理:在文件发送请求时,判断hash值有没有被记录,记录直接使用当前hash值相同的文件
 */
const calcuteHash = (chunks: Array<Blob>) => {
  return new Promise(resolve => {
    const targets: Blob[] = []
    const spark = new SparkMD5.ArrayBuffer()
    // 1. 第一个和最后一个切片全部参与计算
    // 2. 中间的切片只有前两个字节、中间两个字节、后面两个字节参与计算
    chunks.forEach((chunk, index) => {
      if (index === 0 || index === chunks.length - 1) {
        targets.push(chunk)
      } else {
        targets.push(chunk.slice(0, 2)) // 前两个字节
        targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) // 中间两个字节
        targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) // 后面两个字节
      }
    })

    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(new Blob(targets))
    fileReader.onload = (e) => {
      spark.append((e.target as FileReader).result);
      resolve(spark.end());
    }
  })
}

2.2文件上传并发

  • 对于1G的大文件来说,假如每个分片的大小为2M,那么总的分片数将会是2048个,如果同时发送这2048个分片,浏览器肯定处理不了(默认的并发数量只有 6),原因是切片文件过多,游览器处理不了,因此必要限制前端请求个数。
  • 创建最大并发数的请求6个,那么同一时刻允许浏览器只发送6个请求,以此类推
    上传文件时一般还要用到 FormData 对象,需要将要传递的文件还有额外信息放到这个 FormData 对象里面。(切片的hash,大文件的hash,文件名等)
const uploadChunks = async (fileChunks: Array<{file: Blob}>) => {
  const data = fileChunks.map(({ file }, index) => ({
    fileHash: fileHash.value,
    index,
    chunkHash: `${fileHash.value}-${index}`,
    chunk: file,
    size: file.size,
  }))
  const formDatas = data
    .map(({ chunk, chunkHash }) => {      
      const formData = new FormData()
      // 切片文件
      formData.append('chunk', chunk)
      // 切片文件hash
      formData.append('chunkHash', chunkHash)
      // 大文件的文件名
      formData.append('fileName', fileName.value)
      // 大文件hash
      formData.append('fileHash', fileHash.value)
      return formData
    })

  let index = 0;
  const max = 6; // 并发请求数量
  const taskPool: any = [] // 请求队列 
  while(index < formDatas.length) {
    const task = fetch('http://127.0.0.1:3000/upload', {
      method: 'POST',
      body: formDatas[index],
    })
    task.then(() => {
      taskPool.splice(taskPool.findIndex((item: any) => item === task))
    })
    taskPool.push(task);
    if (taskPool.length === max) {
      // 当请求队列中的请求数达到最大并行请求数的时候,得等之前的请求完成再循环下一个
      await Promise.race(taskPool)
    }
    index ++
    percentage.value = (index / formDatas.length * 100).toFixed(0)
  }  
  await Promise.all(taskPool)
}
  • 后端实现
    在处理每个上传的分片的时候,应该先将它们临时存放到服务器的一个地方,方便合并的时候再去读取。为了区分不同文件的分片,就用文件对应的那个hash为文件夹的名称,将这个文件的所有分片放到这个文件夹中。
// 所有上传的文件存放到该目录下
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');

// 处理上传的分片
app.post('/upload', async (req, res) => {
  const form = new multiparty.Form();
  
  form.parse(req, async function (err, fields, files) {
    if (err) {
      res.status(401).json({ 
        ok: false,
        msg: '上传失败'
      });
    }
    const chunkHash = fields['chunkHash'][0]
    const fileName = fields['fileName'][0]
    const fileHash = fields['fileHash'][0]

    // 存储切片的临时文件夹
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

    // 切片目录不存在,则创建切片目录
    if (!fse.existsSync(chunkDir)) {
      await fse.mkdirs(chunkDir)
    }

    const oldPath = files.chunk[0].path;
    // 把文件切片移动到我们的切片文件夹中
    await fse.move(oldPath, path.resolve(chunkDir, chunkHash))

    res.status(200).json({ 
      ok: true,
      msg: 'received file chunk'
    });
  });
});
  • 在上传后uploads 文件夹下就会多一个文件夹,这个文件夹里面就是存储的所有文件的分片了

2.3 文件合并

  • 文件合并前端只需要向服务器发送一个合并的请求,并且为了区分要合并的文件,需要将文件的hash值给传过去
const mergeRequest = () => {  
  // 发送合并请求
  fetch('http://127.0.0.1:3000/merge', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      size: CHUNK_SIZE,
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
  })
    .then((response) => response.json())
    .then(() => {
      alert('上传成功')
    })
}

后端实现

  • 在将所有的切片上传到服务器并存储到对应的目录里面后,合并的时候需要从对应的文件夹中获取所有的切片,然后利用文件的读写操作,就可以实现文件的合并。合并完成之后,将生成的文件以hash值命名存放到对应的位置
// 提取文件后缀名
const extractExt = filename => {
	return filename.slice(filename.lastIndexOf('.'), filename.length)
}
const pipeStream = (path, writeStream) => {
	return new Promise((resolve, reject) => {
		// 创建可读流
		const readStream = fse.createReadStream(path)
		readStream.on('end', async () => {
			fse.unlinkSync(path)
			resolve()
		})
		readStream.pipe(writeStream)
	})
}
/**
 * 合并文件夹中的切片,生成一个完整的文件
 */
async function mergeFileChunk(filePath, fileHash, size) {
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
  const chunkPaths = await fse.readdir(chunkDir)
  // 根据切片下标进行排序
  // 否则直接读取目录的获得的顺序可能会错乱
  chunkPaths.sort((a, b) => {
    return a.split('-')[1] - b.split('-')[1]
  })

  const list = chunkPaths.map((chunkPath, index) => {
    return pipeStream(
      path.resolve(chunkDir, chunkPath),
      fse.createWriteStream(filePath, {
        start: index * size,
        end: (index + 1) * size
      })
    )
  })
  await Promise.all(list)
	// 文件合并后删除保存切片的目录
	fse.rmdirSync(chunkDir)
}
// 合并文件
app.post('/merge', async (req, res) => {
  const { fileHash, fileName, size } = req.body
  const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`)
  // 如果大文件已经存在,则直接返回
  if (fse.existsSync(filePath)) {
    res.status(200).json({ 
      ok: true,
      msg: '合并成功'
    });
    return
  }
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
  // 切片目录不存在,则无法合并切片,报异常
  if (!fse.existsSync(chunkDir)) {
    res.status(200).json({ 
      ok: false,
      msg: '合并失败,请重新上传'
    });
    return
  }
  await mergeFileChunk(filePath, fileHash, size)
  res.status(200).json({ 
    ok: true,
    msg: '合并成功'
  });
});

3. 代码整合

  • 21
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值