大文件上传

113 阅读9分钟

(1).背景:

  • AI 产品方面的,会涉及到用户自定义模型, 经常会遇到一些问题
  • 网络断开之后, 之前传的没了
  • 传着传着,网络波动了, 结果都没了
  • 关机了,想接着传,做不到

(2)专业术语:

  • 断点续传
  • 断开重连重传
  • 切片上传

(3)方案:

  • 前端将文件切片,并发上传,后端接受切片并保存,最后合并切片
  • 前端将文件切片,并发上传,后端接受切片并保存,前端定时检查上传进度,如果发现上传失败,则重新上传
  • 前端将文件切片,并发上传,后端接受切片并保存,前端定时检查上传进度,如果发现上传失败,则重新上传,同时记录上传进度,下次上传时从上次失败的位置开始

(4)原理:

  • 分片上传的原理就像是把一个大蛋糕切成小块一样。
  • 首页,将要上传的文件分成很多小块,每个小块的大小相同,比如每块大小 5MB, 然后,前端会依次上传这些小块到服务器。上传的时候,可以同时上传多个小块,也可以一个一个上传,这取决于你的网络带宽和服务器性能。上传每个小块后,服务器会保存这些小块,并记录他们的顺序和位置。最后,当所有的小块都上传完成后,服务器会把这些小块按照正确的顺序合并成一个完整的文件。
<template>
  <div>
    <h1>大文件上传</h1>
    <input type="file" @change="handleUpload" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import SparkMD5 from 'spark-md5'

const fileHash = ref('')
const fileName = ref('')

// 1MB = 1024KB = 1024 * 1024B

const CHUNK_SIZE = 1024 * 1024 // 1M
// 文件分片的操作
const createChunks = file => {
  let cur = 0
  let chunks = []
  while (cur < file.size) {
    const blob = file.slice(cur, cur + CHUNK_SIZE)
    chunks.push(blob)
    cur += CHUNK_SIZE
  }
  return chunks
}

const calculateHash = chunks => {
  return new Promise(resolve => {
    // 1. 第一个和最后一个切片全部参与计算
    // 2. 中间的切片只计算 前面两个字节,中间的字节,和最后两个字节
    const targets = [] // 参与计算 hash 的切片
    const spark = new SparkMD5.ArrayBuffer()
    const fileReader = new FileReader()

    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)) // 最后两个字节
      }
    })

    fileReader.readAsArrayBuffer(new Blob(targets))
    fileReader.onload = e => {
      spark.append(e.target.result)
      const hash = spark.end() // 拿到 hash 值
      resolve(hash)
    }
  })
}

const mergeRequest = () => {
  fetch('http://localhost:3000/merge', {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE
    })
  }).then(res =>  {
    alert('合并成功了~~')
  })
  
}

const uploadChunks = async (chunks, existChunks) => {
  const data = chunks.map((chunk, index) => {
    return {
      fileHash: fileHash.value,
      chunkHash: fileHash.value + '-' + index,
      chunk
    }
  })

  const formDatas = data
  .filter( item =>  !existChunks.includes(item.chunkHash))
  .map(item => {
    const formData = new FormData()
    formData.append('fileHash', item.fileHash)
    formData.append('chunkHash', item.chunkHash)
    formData.append('chunk', item.chunk)

    return formData
  })

  // console.log(formDatas);

  const max = 6 // 最大并发数
  let index = 0
  const taskPool = [] // 请求池

  while (index < formDatas.length) {
    const task = fetch('http://localhost:3000/upload', {
      method: 'POST',
      body: formDatas[index]
    })
    taskPool.splice(taskPool.findIndex(item => item === task))
    taskPool.push(task)
    if (taskPool.length >= max) {
      await Promise.race(taskPool)
    }
    index++
  }
  await Promise.all(taskPool)
  // 通知服务器合并切片
  mergeRequest()
}

const verify =  () => {
  return fetch('http://localhost:3000/verify', {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value
    })
  }).then(res => res.json())
  .then(res => {
    return res
  })
}

const handleUpload = async e => {
  // 注意:  e.target.files  伪数组
  const files = e.target.files
  if (!files) return
  // 读取选择的文件
  const file = files[0]
  fileName.value = file.name

  // 文件分片
  const chunks = createChunks(file)
  // console.log(chunks);

  // 计算hash 值
  const hash = await calculateHash(chunks) 
  // console.log(hash);
  fileHash.value = hash
  
  // 效验hash 值 是否已经上传过 ,  必须等有 hash 值之后才能效验
  const data = await verify() 
  console.log(data);
  if(!data.data.isUploaded) {
    alert('妙传: 上传成功了~')
    return
  }

  // 上传分片
  uploadChunks(chunks, data.data.existChunks)
}

/**
   * 文件分片
   * 核心用 Blob 对象的 slice 方法, 在前面一步获取选择的文件一个 File对象, 它继承于 Blob 对象,所以可以使用 slice 方法对文件其进行分片
   * let  blob = instanceOfBlob.slice(start, end, contentType)
   * start 和 end 代表 Blob里的下标,表示被拷贝进新的 Bob 的字节的起始位置和结束位置。contentType 会给新的 Bob 赋予一个新的文
   * 档类型,在这里我们用不到。接下来就来使用slice方法来实现下对文件的分片。
   * 
   * hash 计算
   * 在向服务器发送文件之前,怎么区分不同的文件呢? 如果根据文件名区分的话可以吗?
   * 答案: 不行,因为文件名是可以重复的,也可以修改的。所以我们需要根据文件内容来区分不同的文件,这里就需要用到 hash 算法了。
   * 可以根据 文件内容生成一个唯一的 hash 值,来区分不同的文件。 文件内容变化, hash 值也会变化。 而且通过这个办法,还可以 实现妙传的功能。
   * 妙传: 就是说,服务器上传文件请求的时候,要先判断下对应文件的hash值有没有记录, 如果 A 和 B 先后 上传了同一个文件,
   * 那么这两份文件对应的 hash 值是相同的,当A上传的时候会根据文件内容生成一个对应的 bash 值,然后在服务器上就会有一个对应的文件,
   * B再上传的时候,服务器就会发现这个文件的 hash 值之前已经有记录了,说明之前已经上传过相同内容的文件了,
   * 所以就不用处理B的这个上传请求了,给用户的感觉就像是实现了秒传。
   * 那么怎么计算文件的hash值呢?可以通过一个工具:spark-md5,所以我们得先安装它。
   * 在上一步获取到了文件的所有切片,我们就可以用这些切片来算该文件的hash 值,但是如果一个文件特别大,每个切片的所有内容都参与计算的话会很耗时间,所有我们可以采取以下策略:
      1.第一个和最后一个切片的内容全部参与计算
      2.中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
      这样就既能保证所有的切片参与了计算,也能保证不耗费很长的时间


      
   *  文件上传  
   *  前端实现
   *  前面已经完成了上传的前置操作,接下来就来看下如何去上传这些切片
      我们以1G的文件来分析,假如每个分片的大小为1M,那么总的分片数将会是1024个,如果我们同时发送这1024个分片,浏览器肯定处理不了,原因是切片文件过多,浏览器一次性创建了太多的请求。这是没有必要的,拿 chrome 浏览器来说,默认的并发数量只有6,过多的请求并不会提升上传速度,反而是给浏览器带来了巨大的负担。因此,我们有必要限制前端请求个数。
      怎么做呢,我们要创建最大并发数的请求,比如6个,那么同一时刻我们就允许浏览器只发送6个请求,其中一个请求有了返回的结果后我们再发起一个新的请求,依此类推,直至所有的请求发送完毕。
      上传文件时一般还要用到 Formpata 对象,需要将我们要传递的文件还有额外信息放到这个 Formpata 对象里面

      文件合并
      上一步我们已经实现了将所有切片上传到服务器了,上传完成之后,我们就可以将所有的切片合并成一个完整的文件了,下面就一块来实现下。
      前端实现  : 前端只需要向服务器发送一个合并的请求,并且为了区分要合并的文件,需要将文件的hash值给传过去

      注意: 已经大文件上传的分片上传的基本功能了,但是没有考虑到如果上传相同的文件的情况,而且如果中间网络断了,
      就得重新上传,这些情况在大文件上传中都是需要考虑的。

      妙传 & 断点续传
      我们在上面有提到,如果内容相同的文件进行hash计算时,对应的hash值应该是一样的,
      而且我们在服务器上给上传的文件命名的时候就是用对应的hash值命名的,所以在上传之前是不是可以加一个判断,
      如果有对应的这个文件,就不用再重复上传了,直接告诉用户上传成功,
      给用户的感觉就像是实现了秒传。接下来,就来看下如何实现的。

      前端实现
      前端在上传之前,需要将对应文件的hash值告诉服务器,看看服务器上有没有对应的这个文件,
      如果有,就直接返回,不执行上传分片的操作了。
   */
</script>

<style scoped></style>


后端

const express = require("express");
const path = require("path");
const multiparty = require("multiparty");
const fse = require("fs-extra");
const cors = require("cors");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());
app.use(cors());

// 提取文件名后缀
const getSuffix = (fileName) => {
  return fileName.substring(fileName.lastIndexOf("."));
};

const UPLOAD_DIR = path.resolve(__dirname, "uploads");

app.post("/upload", (req, res) => {
  const form = new multiparty.Form();
  form.parse(req, async (err, fields, files) => {
    if (err) {
      res.status(401).json({
        ok: false,
        message: "上传失败",
      });
      return;
    }

    console.log("fields", fields);
    console.log("files", files);
    const fileHash = fields.fileHash[0];
    const chunkHash = fields.chunkHash[0];
    // 临时存放文件路径
    const chunkPath = path.resolve(UPLOAD_DIR, fileHash);

    if (!fse.existsSync(chunkPath)) {
      await fse.mkdir(chunkPath);
    }

    const oldPath = files.chunk[0].path;
    // 将切片放到这个
    await fse.move(oldPath, path.resolve(chunkPath, chunkHash));

    res.status(200).json({
      ok: true,
      message: "上传成功",
    });
  });
});

app.post("/merge", async (req, res) => {
  const { fileHash, fileName, size } = req.body;

  // 如果已经存在该文件,就没必要合并了
  const filePath = path.resolve(
    UPLOAD_DIR,
    `${fileHash}${getSuffix(fileName)}`
  );

  if (fse.existsSync(filePath)) {
    res.status(200).json({
      ok: true,
      message: "合并成功",
    });
    return;
  }

  // 如果不存在,则合并
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  if (!fse.existsSync(chunkDir)) {
    res.status(400).json({
      ok: false,
      message: "合并失败,请重新上传",
    });
    return;
  }

  // 真正的合并操作
  const chunkPaths = await fse.readdir(chunkDir);
  chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);

  const list = chunkPaths.map((chunkName, index) => {
    return new Promise(async (resolve) => {
      const chunkPath = path.resolve(chunkDir, chunkName);
      const readStream = fse.createReadStream(chunkPath);
      const writeStream = fse.createWriteStream(filePath, {
        start: index * size,
        end: (index + 1) * size,
      });
      readStream.on("end", async () => {
        await fse.unlink(chunkPath);
        resolve();
      });
      // 将切片写入到文件中
      readStream.pipe(writeStream);
    });
  });

  // 等待所有切片合并完成
  await Promise.all(list);
  // 删除临时文件夹
  await fse.remove(chunkDir);

  res.status(200).json({
    ok: true,
    message: "合并成功",
  });
});

app.post("/verify", async (req, res) => {
  const { fileHash, fileName } = req.body;
  const filePath = path.resolve(
    UPLOAD_DIR,
    `${fileHash}${getSuffix(fileName)}`
  );

  // 返回服务器已经存在的切片
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
  let chunkPaths = [];
  if (fse.existsSync(chunkDir)) {
    chunkPaths = await fse.readdir(chunkDir);
  }

  if (fse.existsSync(filePath)) {
    res.status(200).json({
      ok: true,
      data: {
        isUploaded: false,
      },
    });
  } else {
    // 如果不存在,则需要重新上传
    res.status(200).json({
      ok: true,
      data: {
        isUploaded: true,
        existChunks: chunkPaths,
      },
    });
  }
});

app.listen(3000, () => {
  console.log(`服务器启动成功:http://localhost:3000`);
});