Un artículo para comprender la carga de archivos grandes

Una solución:
el front-end uppy.js
y el back-end tusd+minio (ambos implementados en Go, se puede ejecutar un archivo binario arrojándolo, y el mismo nombre de dominio necesita un agente previo)
agrupamiento para implementar el tu protocolo por sí mismo
, pero no es necesario para proyectos pequeños ordinarios. Solo ejecútelo en una sola máquina y listo.
Esta debería ser la solución más simple para la reanudación de puntos de división de archivos muy grandes, y guarda el frente y back termina de definir este tipo de cosas durante mucho tiempo.

detalles

En comparación con la carga tradicional de archivos pequeños, la carga de archivos grandes debe prestar atención a algunos detalles para garantizar la estabilidad y confiabilidad del proceso de carga. Los siguientes son algunos detalles que requieren atención:

  1. Tamaño del fragmento de archivo: el tamaño del fragmento de archivo debe considerarse de manera integral en función de factores como el ancho de banda de la red y el rendimiento del servidor. En general, se recomienda configurarlo en 1 MB ~ 10 MB para evitar fallas en la carga causadas por fragmentos demasiado grandes.
  2. Número de serie del fragmento de archivo: al cargar fragmentos, debe especificar un número de serie único para cada fragmento, de modo que los fragmentos se puedan clasificar y fusionar durante el proceso de carga.
  3. Progreso de carga de la partición: para mejorar la experiencia del usuario, es necesario mostrar el progreso de la carga en tiempo real. El progreso de la carga se puede mostrar calculando la relación entre el tamaño de la parte cargada y el tamaño total del archivo en tiempo real a través del frente. fin.
  4. Carga de reanudación de punto de interrupción: la carga de reanudación de punto de interrupción es la clave para garantizar la confiabilidad de la carga. Es necesario registrar la información del fragmento cargado en el backend, de modo que cuando la red es anormal o la carga se interrumpe, la carga puede continuar desde el lugar donde fue interrumpido la última vez.
  5. Verificación del archivo: una vez completada la carga, el archivo cargado debe verificarse para garantizar la integridad y corrección del archivo.
  6. Límite de carga simultánea: para cargas de archivos grandes, es necesario limitar la cantidad de fragmentos cargados al mismo tiempo para evitar la sobrecarga del servidor y el uso excesivo de ancho de banda.
  7. Control de velocidad de carga: la velocidad de carga debe controlarse para evitar la congestión de la red y los errores durante el proceso de carga.
  8. Fusionar fragmentos: una vez completada la carga, todos los fragmentos deben fusionarse en un archivo completo, que debe tener en cuenta cuestiones como el orden de los fragmentos y las rutas de los archivos.
  9. Almacenamiento de archivos: debe considerar dónde y cómo se almacenan los archivos para su posterior acceso y administración. Después de cargar archivos grandes, es necesario almacenarlos. Los métodos de almacenamiento comunes incluyen el almacenamiento local, el almacenamiento de objetos y los sistemas de archivos distribuidos. Los diferentes métodos de almacenamiento tienen diferentes características y ventajas y desventajas, y deben seleccionarse de acuerdo con los requisitos comerciales.
  10. Subir restricciones de tipo de archivo: debe establecer restricciones de tipo de archivo de carga de acuerdo con las necesidades comerciales para evitar cargar archivos ilegales.
  11. Procesamiento de tiempo de espera de carga: cuando el tiempo de carga es demasiado largo o la red se interrumpe, se requiere el procesamiento de tiempo de espera correspondiente para evitar la ocupación a largo plazo del ancho de banda o los recursos del servidor.
  12. Reintento de carga fallida: durante el proceso de carga, la carga puede fallar y los fragmentos que no se pudieron cargar deben retransmitirse para garantizar la integridad de la carga.
  13. Autorización de carga de archivos: para escenarios en los que se requiere autorización de carga de archivos, el usuario que carga debe estar autenticado y autorizado para garantizar la legalidad y seguridad de los archivos cargados.
  14. Cambiar el nombre del archivo cargado: para garantizar la unicidad del archivo, puede ser necesario cambiar el nombre del archivo cargado, como agregar una marca de tiempo o una cadena aleatoria.
  15. Copia de seguridad de archivos y recuperación ante desastres: para garantizar la seguridad y la confiabilidad de los archivos, los archivos cargados deben respaldarse y recuperarse ante desastres. La copia de seguridad y la recuperación ante desastres se pueden llevar a cabo mediante la implementación de salas de múltiples computadoras, el almacenamiento de múltiples copias, la recuperación remota ante desastres, etc.

Transferencia simple de archivos largos
Continuar desde la última vez.

subir rebanada

rebanada sencilla

Justificación: los blobs son inmutables como las cadenas, pero se pueden dividir. El archivo hereda todos los métodos de blob.
El archivo file.slice(start, end)se corta con el método, así que registre el tamaño del bloque de cada corte y la posición de cada corte inicial.

const handleClickUpload = async () => {
    
    
  const formData = new FormData()

  const _bigFile = fileList.value[0].file
  const _fileSize = _bigFile.size
  const _chunkSize = 1 * 1024 * 1024 // 1MB 注意:文件大小单位是字节
  let _currentSize = 0

  while (_currentSize < _fileSize) {
    
    
    // 切片
    let _chunk = _bigFile.slice(_currentSize, _currentSize + _chunkSize)
    
    formData.append('avatar', _chunk, _bigFile.name)
    
    _currentSize += _chunkSize

    // 上传
    await fetch('http://localhost:8888/upload', {
    
    
      method: 'post',
      body: formData
    })
    
    // 进度,注意一定要在本次上传完毕后更改,要不然同步任务先执行直接 100%
    progress.value = Math.min((_currentSize / _fileSize) * 100, 100)
    // upload(formData)
  }
}

MD5 comprueba la consistencia del archivo

SparkMD5.js
SparkMD5 es una biblioteca de JavaScript para calcular hashes MD5 de cadenas. Se puede usar en el navegador y en el entorno Node.js. SparkMD5 utiliza un algoritmo hash de transmisión basado en bits, que es eficiente y confiable. Es ampliamente utilizado para calcular el valor MD5 de los archivos, así como en criptografía y verificación de integridad de datos.

// 计算字符串MD5哈希
const hash = SparkMD5.hash('hello world');
console.log(hash); // 输出: 5eb63bbbe01eeed093cb22bb8f5acdc3

// 计算文件MD5哈希
const fileInput = document.querySelector('input[type="file"]');
const file = fileInput.files[0];
const fileReader = new FileReader();

fileReader.onload = function() {
    
    
  const spark = new SparkMD5.ArrayBuffer();
  spark.append(fileReader.result);
  const hash = spark.end();
  console.log(hash); // 输出: 文件的MD5哈希值
};

fileReader.readAsArrayBuffer(file);

¿Por qué necesita calcular MD5?
Porque la función de transferencia instantánea necesita usar MD5 para juzgar si el archivo se ha cargado.
Antes de enviar, envíe el valor hash del archivo al servidor, y el servidor mantendrá el hash del archivo cargado.Después de una comparación, sabrá si el archivo se ha cargado repetidamente y notificará al cliente que no lo cargue, y la barra de progreso será del 100%.

trabajador web

Cabe señalar que al cortar un archivo grande, como 10G, si se divide en 1Mb, se generarán 10.000 segmentos y también se calculará el valor hash de todo el archivo. Como todos sabemos, js es un modelo de un solo hilo. Si el proceso de cálculo está en el hilo principal, nuestra página inevitablemente se bloqueará directamente. En este momento, es hora de que nuestro Web Worker juegue.
WebWorker
webWorker carga módulos de terceros
En worker.js, necesitamos dividir el archivo y devolver la matriz de estos segmentos y el valor hash de todo el archivo al subproceso principal.

Pero hay un problema: el búfer debe agregarse a la chispa, es decir, el blob obtenido después de cortar el archivo debe convertirse fileReader.readAsArrayBuffer(blob)en un búfer. Lo que es más importante, el resultado debe recuperarse fileReader.onload = fn(){}en la devolución de llamada, que es una operación asíncrona. spark.append(e.target.result)Esto genera un problema: el orden de los fragmentos agregados a ( )spark puede estar fuera de orden, lo que conducirá a un valor incorrecto al calcular el hash general al final.

Solución: Procese recursivamente el siguiente fragmento.
Solo cuando esté claro que el fragmento anterior se ha agregado a chispa, se cortará el siguiente fragmento.

// worker.js
importScripts("./SparkMD5.js");
// 接收文件对象及切片大小
self.onmessage = (e) => {
    
    
  const {
    
     file, DefualtChunkSize } = e.data;
  const blobSlice =
    File.prototype.slice ||
    File.prototype.mozSlice ||
    File.prototype.webkitSlice;
  const chunks = Math.ceil(file.size / DefualtChunkSize); // 分片数
  let currentChunk = 0 // 当前分片索引
  const chunkList = [] // 分片数组
  const spark = new SparkMD5.ArrayBuffer();
  const fileReader = new FileReader();

  fileReader.onload = function (e) {
    
    
 
    spark.append(e.target.result);
    currentChunk++;

    if (currentChunk < chunks) {
    
    
      loadNext();
    } else {
    
    
      const fileHash = spark.end();
      console.info("finished computed hash", fileHash);
      // 此处为重点,计算完成后,仍然通过postMessage通知主线程
      postMessage({
    
     fileHash, chunkList });
      self.close() // 关闭线程
    }
  };

  fileReader.onerror = function () {
    
    
    console.warn("oops, something went wrong.");
  };

  function loadNext() {
    
    
    let start = currentChunk * DefualtChunkSize;
    let end =
      start + DefualtChunkSize >= file.size
        ? file.size
        : start + DefualtChunkSize;
    const chunk = blobSlice.call(file, start, end);
    chunkList.push({
    
    chunk, currentChunk})
    fileReader.readAsArrayBuffer(chunk);
  }

  loadNext();
};

// 响应给主线程的数据结构
// { fileHash, [{blobChunk, currentChunk}, {}, {}...] }

cargar fragmentos

Cada fragmento se carga como un archivo separado. En lugar de cargar toda la matriz de segmentos juntos, esto es para un control más preciso y también es un requisito previo para las cargas simultáneas.

const uploadChunk = (fileChunk, fileHash, fileName) => {
    
    

  const formData = new FormData()
  formData.append('chunk', fileChunk.chunk); // 每个分片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('fileName', fileName); // 文件名称
  formData.append('fileHash', fileHash); // 整个文件的 hash

  return lcRequest.post({
    
    
    url: '/upload',
    data: formData
  })
}

Si desea cargar los fragmentos del archivo completo, solo necesita recorrer la matriz de fragmentos. El siguiente ejemplo carga los fragmentos uno por uno en estricto orden.

solicitud de fusión

Una vez que se completa la carga, generalmente se envía una solicitud de fusión al servidor, informándole que la carga se ha completado y que los fragmentos se pueden fusionar.

// 合并请求
const mergeChunkRequest = (filename, fileHash) => {
    
    
  return lcRequest.post({
    
    
    url: '/api/merge',
    data: {
    
     filename, hash: fileHash }
  })
}
worker.postMessage({
    
     file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
    
    
  
	const {
    
     chunkList, fileHash } = e.data

  // 循环上传分片
	for (const chunk of chunkList) {
    
    
    const uploadChunkFinish = await uploadChunk(chunk, fileHash, file.name, file.size)
    console.log(uploadChunkFinish);
  }

  // 合并请求
  const res = await mergeChunkRequest(file.name, fileHash)
  console.log(res);
}

Currículum de punto de interrupción y segunda transferencia

Segundo pase

Antes de enviar un archivo, debe verificar si el archivo se ha cargado.

const verifyUpload = (filename, hash) => {
    
    
  return lcRequest.post({
    
     // lcRequest 封装自 axios
    url: '/verify',
    data: {
    
     filename, hash },
    headers: {
    
     'Content-Type': 'application/json' },
  })
}

La interfaz del servidor generalmente tiene tres respuestas:

  1. El archivo ha sido subido, devuelve verdadero
  2. El archivo no se carga, devuelve falso
  3. El archivo está medio cargado, devuelve el tamaño cargado y el nombre del campo de formulario del archivo. (error)

http

El tercer tipo de respuesta es la reanudación de la transmisión. De hecho, al devolver el tamaño subido, no es apropiado. Solo se aplica a cargas de un solo archivo o cuando las partes se cargan estrictamente en orden.

const resumeUpload = (uploadedSize, fileSize, defualtChunkSize) => {
    
    
  const progress = 100 * uploadedSize / fileSize

  // 计算恢复上传的切片索引
  const index = uploadedSize % defualtChunkSize
  return {
    
    index, progress}
}

Si el front-end se está cargando al mismo tiempo, puede ocurrir que el fragmento anterior se desconecte y se haya cargado el fragmento posterior. En este punto, el servidor debe mostrar claramente qué fragmentos se han cargado.
El servidor generalmente devuelve el índice de los fragmentos cargados. Si el servidor no distingue entre partes cargadas completamente y partes cargadas parcialmente, devolverá sus índices. Luego, simplemente juzgue cuál de estos fragmentos es de tamaño relativamente pequeño y deseche los pequeños directamente.
Obtenemos la matriz de sectores completa, filtramos los sectores cargados y obtenemos la matriz de sectores no cargados, y solo subimos uno por uno en este momento.

// uploadedList 为 [切片1, 切片2, ..., 切片n] 
// 已上传切片的地址文件名为文件hash+分片索引。 eg:[13717432cb479f2f51abce2ecb318c13-1.mp3]

const resumeUpload = (uploadedList, chunkList) => {
    
    

  // 从服务器返回的数据中拿到分片的索引
  const uploadedIndexList = uploadedList.map(item => {
    
    
    return item.match(/-(\d+)\./)
  })
  // 过滤出未上传的切片
  const resumeChunkList = chunkList.filter(item => {
    
    
    return !uploadedIndexList.includes(item.currentChunk)
  })
  
  return resumeChunkList 
}

La estructura principal del proceso de carga.

worker.postMessage({
    
     file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
    
    
  const {
    
     chunkList, fileHash } = e.data

  // 秒传验证
  const {
    
     uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
  if (uploadedSignal === true) {
    
    
    // 秒传,进度条百分之百
    progress.value = 100
  } else if (uploadedSignal === false && uploadedList.length === 0) {
    
    
    // 从0上传
  	...
  } else {
    
    
    // 续传
    const {
    
     resumeProgress, resumeChunkList } = resumeUpload(uploadedList, chunkList)
    ...
  }
}

cargar el progreso

Progreso de carga de un solo archivo

fetch no puede monitorear el progreso de carga, xhr sí. Axios también proporciona monitoreo del progreso de carga y descarga.
Axios supervisa el progreso de la carga

  • onUploadProgress: configurar la devolución de llamada para el progreso de carga
  • onDownloadProgress: configure la devolución de llamada para el progreso de la descarga

Todas son configuraciones exclusivas del navegador.

axios.post('/upload', formData, {
    
     onUploadProgress: uploadProgress })

const uploadProgress = (progressEvent) => {
    
    
  // 计算上传进度,loaded 和 total 是该事件对象固有的属性
  const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
  console.log(percentCompleted);
}

Durante el proceso de carga, la función de devolución de llamada configurada por onUploadProgress se llamará de manera oportuna.
imagen.png

Progreso de carga de varias partes

Debido a que estamos cargando fragmentos uno por uno, si usamos el esquema de monitoreo de progreso predeterminado de axios, solo se mostrará el progreso de un determinado fragmento, y es imposible saber el progreso de qué fragmento, y mucho menos el progreso general. es así, por lo que es necesario realizar algunos cálculos aquí, y el método de cálculo también es muy simple, es decir, sume los tamaños de archivo cargados de cada segmento y divida por el tamaño total.

La fragmentación es equivalente a archivos individuales, por lo que también se puede decir que este método es un método de cálculo de progreso general para cargas de archivos múltiples.

subir progreso desde 0

const progressArr = [] // 记录每个分片已经上传的大小
const uploadProgress = (progressEvent, chunkIndex, totalSize) => {
    
    
  if (progressEvent.total) {
    
    
    // 将当前分片的已经上传的大小按分片索引保存在数组中
    progressArr[chunkIndex] = progressEvent.loaded * 100;
    // reduce 累加每块分片已上传的部分
    const curTotal = progressArr.reduce(
      (accumulator, currentValue) => accumulator + currentValue,
      0,
    );
    // 计算百分比进度
    progress.value = Math.min((curTotal / totalSize).toFixed(2), 100)
    // setProgress((curTotal / totalSize).toFixed(2)); // 发布订阅者模式
  }
};

La barra de progreso puede aplicar el modo de publicación-suscriptor para recibir cambios de datos y, por supuesto, no se puede usar.

Nota: De manera predeterminada, la devolución de llamada de monitoreo del progreso de carga de axios tiene solo un parámetro. Si desea pasar varios parámetros, puede envolverlo con una función.
Por ejemplo, la devolución de llamada anterior que maneja el monitoreo tiene múltiples parámetros, por lo que no se puede vincular directamente a ella onUploadProgress.

onUploadProgress: (progressEvent) => {
    
    
  return uploadProgress(progressEvent, formData.get('chunkIndex', fileSize)
})

Debido a que el cálculo del progreso requiere el tamaño total del archivo, se agrega un tamaño de archivo adicional al parámetro de la función de carga de varias partes.

const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
    
    
  const formData = new FormData()
  
  formData.append('file', fileChunk.chunk); // 切片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('filename', fileName); // 文件名称
  formData.append('hash', fileHash); // 文件 hash

  return lcRequest.post({
    
    
    url: '/api/upload',
    data: formData,
    // 默认回调只有一个参数,想要传递两个,可以用函数包裹一层
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
}

Reanudación del progreso de la carga interrumpida

De lo anterior, podemos obtener la matriz de índice de los fragmentos cargados. Dado que el cálculo del progreso se progressArrrealiza registrando el tamaño cargado de cada fragmento en , basta con llenar primero los fragmentos cargados progressArr.
Modifique la función resumeUpload resumeUpload para devolver la matriz de índice cargada:

// 断点续传
const resumeUpload = (uploadedList, chunkList) => {
    
    
  const uploadedIndexList = uploadedList.map(item => {
    
    
    return item.match(/-(\d+)\./)
  })
  const resumeChunkList = chunkList.filter(item => {
    
    
    return !uploadedIndexList.includes(item.currentChunk)
  })
  return {
    
     uploadedIndexList, resumeChunkList }
}

Primero complete el tamaño de los fragmentos cargados en ProgressArr y luego acumule:

const {
    
     uploadedIndexList, resumeChunkList } = resumeUpload(uploadedList, chunkList)

// 填入 progressArr
uploadedIndexList.forEach(index => {
    
    
  progressArr[index] = chunkSize
});

// 将未上传的分片数组中将分片一个一个循环串行上传
for (const chunk of resumeChunkList) {
    
    
  const uploadChunkFinish = await uploadChunk(chunk, fileHash, file.name, file.size)
  console.log(uploadChunkFinish);
}

const res = await mergeChunkRequest(file.name, fileHash)
console.log(res);

cancelar carga

Para detener la carga, puede cerrar la página o el navegador actual, desconectar el backend o detenerlo de forma activa mediante la API de frontend. Las dos situaciones anteriores son algunas situaciones inesperadas. Aquí discutimos principalmente la situación de usar la API de front-end para detenerse activamente.
Para cancelar la solicitud de axios
, simplemente agregue un parámetro de señal al método de carga de segmentos para configurar axios.

// 切片上传
const controller = new AbortController();
const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
    
    
	...
  
  return lcRequest.post({
    
    
    url: '/api/upload',
    data: formData,
    signal: controller.signal,
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
const handleClickAbortUpload = () => {
    
    
  controller.abort()
}

pausa, reanudar solicitud

Suspender y reanudar es cancelar la solicitud y reanudar el punto de interrupción.

control de concurrencia

El método de carga de fragmentos anterior es en serie y la eficiencia no es lo suficientemente alta. Podemos cargar simultáneamente, pero el navegador puede realizar hasta 6 solicitudes simultáneas, por lo que es necesario controlar las solicitudes simultáneas.
control de solicitudes concurrentes

async function controlConcurrency(requests, limit) {
    
    
  const results = []; // 结果数组
  const running = []; // 并发数组

  for (const request of requests) {
    
    
    const promise = request();

    results.push(promise);
    running.push(promise);

    if (running.length >= limit) {
    
    
      await Promise.race(running);
    }

    promise.finally(() => {
    
    
      running.splice(running.indexOf(promise), 1);
    });
  }

  return Promise.all(results);
}

Las matrices fragmentadas se cargan simultáneamente:

// 分片数组并发上传
const chunkListUpload = (chunkList, uploadChunk, fileHash, file, concurrentControlFn, limit = 3) => {
    
    
  // 构建请求数组
  const requestsArr = []
  for (let index = 0; index < chunkList.length; index++) {
    
    
    requestsArr[index] = () => uploadChunk(chunkList[index], fileHash, file.name, file.size)
  }
  // 并发控制
  return concurrentControlFn(requestsArr, limit)
}

Proceso completo de control de carga:

worker.postMessage({
    
     file: file, DefualtChunkSize: chunkSize })
worker.onmessage = async e => {
    
    
  const {
    
     chunkList, fileHash } = e.data

  // 1. 秒传验证
  const {
    
     uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
  if (uploadedSignal === true) {
    
    
    // 秒传,进度条百分之百
    progress.value = 100
  } else if (uploadedSignal === false && uploadedList.length === 0) {
    
    
    // 2. 从0上传
  	const chunkListUploadRes = await chunkListUpload(chunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
    console.log(chunkListUploadRes);
  	// 合并
    const res = await mergeChunkRequest(file.name, fileHash)
    console.log(res);
  } else {
    
    
    // 3. 续传
    const {
    
     resumeProgress, resumeChunkList } = resumeUpload(uploadedList, chunkList)
    // 填入 progressArr
    uploadedIndexList.forEach(index => {
    
    
      progressArr[index] = chunkSize
    });
    
    const chunkListUploadRes = await chunkListUpload(resumeChunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
    console.log(chunkListUploadRes);
    
    const res = await mergeChunkRequest(file.name, fileHash)
    console.log(res);
  }
}

servidor

El servidor aquí simplemente implementa el empalme de fragmentos, pero no implementa la reanudación del punto de interrupción ni la segunda transmisión.

Proceso:

  1. Carga de varias partes

La carga de varias partes es en realidad una carga de varios archivos, por lo que debe usar el método de matriz de multer para recibir varias partes. Los fragmentos existen principalmente en una carpeta temporal llamada hash. El uso de hash como nombre garantiza la unicidad de la carpeta temporal y también sienta las bases para reanudar las cargas desde los puntos de interrupción.

  1. fusionar fragmentos

Fusionar fragmentos consiste en ordenar los fragmentos en la carpeta temporal de acuerdo con el índice, y luego leerlos secuencialmente y generarlos como un archivo.
El módulo fs-extra puede procesar fácilmente archivos y fusionar fragmentos:fse.appendFileSync(合并到的文件, 分片的二进制数据)

const Koa = require("koa");
const cors = require("koa-cors");
const Router = require("koa-router");
const bodyParser = require("koa-bodyparser");
const multer = require("@koa/multer");
const path = require('path')
const fse = require("fs-extra");

const app = new Koa();

const router = new Router({
    
    
  prefix: '/api',
})

const UPLOAD_DIR = './upload/'
// 新建一个以 hash 为名的临时文件夹保存分片,然后读取分片合并成原始文件,然后删除临时文件夹
// 发现 req 中获取不到 hash 值,于是暂时固定一个临时文件夹使用,用完了就删,然后再新建。
// 但是这样就无法保存传了一半的文件了。
const tempDir = './upload/temp'

// 上传文件存储配置
const storage = multer.diskStorage({
    
    
  destination: (req, file, cb) => {
    
    
    // console.log(req.body.hash); // req 中无法获取到 hash 值,用来创建临时文件夹
    cb(null, tempDir);
  },
  filename: (req, file, cb) => {
    
    
    cb(null, `${
      
      file.originalname}_${
      
      Date.now()}${
      
      path.extname(file.originalname)}`);
  },
});

const upload = multer({
    
     storage });

router.get("/test", (ctx) => {
    
    
  console.log("get request coming");
  ctx.response.body = JSON.stringify({
    
     msg: "get success" });
});

// 解析 formdata 文件,分片上传
// array(字段)必须和前端发送的表单字段一致
router.post("/upload", upload.array('file'), (ctx, dispatch) => {
    
    
  const {
    
     filename, hash } = ctx.request.body;
  const fileInfoList = ctx.request.files
  const uploadFile = []
  fileInfoList.forEach(item => {
    
    
    uploadFile.push(item.originalname)
  })
  ctx.response.body = JSON.stringify({
    
     msg: "post success" , uploadFile });
});

// 合并分片
router.post("/merge", async (ctx) => {
    
    
  const {
    
     filename, hash } = ctx.request.body;

  // 获取文件后缀
  const ext = filename.slice(filename.lastIndexOf('.') + 1)

  // 从临时文件夹中获取分片
  const chunks = await fse.readdir(tempDir);

  // 将分片排序后,循环读入内存然后添加到一个文件中
  chunks
    .sort((a, b) => Number(a) - Number(b))
    .forEach((chunk) => {
    
    
      // 合并文件
      fse.appendFileSync(
        path.join(UPLOAD_DIR, `${
      
      hash}.${
      
      ext}`), 
    		fse.readFileSync(path.join(tempDir, chunk))
      );
    });
  
  // 删除临时文件夹
  fse.removeSync(tempDir);
  fse.mkdir(tempDir)
  
  // 可能会返回文件下载地址
  ctx.body = "合并成功";
});


app.use(bodyParser());
app.use(cors());
app.use(router.routes());
app.use(router.allowedMethods());

app.listen(8888, "127.0.0.1", () => {
    
    
  console.log("server start...");
});

código completo

<template>
  <div class="upload-box">
    <div class="head">
      <h3> 切片上传</h3>
      <input class="upload-btn" type="file" @change="handleFileChange">
    </div>
    <div class="preview-box">
      待上传文件:
      <div class="files-info">
        <template v-for="item in fileList" :key="item.lastModified">
          <div class="card">
            <div class="delete">
              <!-- <img :src="item.preUrl" alt="预览图"> -->
              <video :src="item.preUrl"></video>
              <div class="name">{
   
   { item.file.name }}</div>
            </div>
          </div>
        </template>
      </div>
    </div>
    <template v-if="progress != 0">
      <div class="progress">
        <div class="background">
          <div class="foreground" :style="{ width: progress + '%' }"></div>
        </div>
      </div>
    </template>
    <div class="result-box">
      上传反馈:
      <div class="result">

        <div>{
   
   { result.msg }}</div>
        <div>{
   
   { result.uploadFile }}</div>
      </div>
    </div>
    <div class="action">
      <button @click="handleClickUpload">上传</button>
      <button @click="handleClickAbortUpload">取消</button>
      <a href="" download="">xia zai</a>
    </div>
  </div>
</template>

<script setup>
import {
      
       ref } from 'vue'
import lcRequest from '../service/request';
import {
      
       controlConcurrency } from '../utils/concurrentControl';

const result = ref('')
const fileList = ref([])
const progress = ref('0')
const fileType = ['png', 'mp4', 'mkv']
const chunkSize = 10 * 1024 * 1024

// 切片上传
const controller = new AbortController();
const uploadChunk = (fileChunk, fileHash, fileName, fileSize) => {
      
      
  console.log(fileChunk);
  console.log(fileSize);
  const formData = new FormData()
  formData.append('file', fileChunk.chunk); // 切片
  formData.append('chunkIndex', fileChunk.currentChunk); // 块索引
  formData.append('filename', fileName); // 文件名称
  formData.append('hash', fileHash); // 文件 hash

  return lcRequest.post({
      
      
    url: '/api/upload',
    data: formData,
    signal: controller.signal,
    // 默认回调只有一个参数,想要传递两个,可以用函数包裹一层
    onUploadProgress: (progressEvent) => uploadProgress(progressEvent, formData.get('chunkIndex'), fileSize)
  })
}

// 进度计算
const progressArr = []
const uploadProgress = (progressEvent, chunkIndex, totalSize) => {
      
      
  if (progressEvent.total) {
      
      
    // 将当前分片的已经上传的字符保存在数组中
    progressArr[chunkIndex] = progressEvent.loaded * 100;
    // reduce 累加每块分片已上传的部分
    const curTotal = progressArr.reduce(
      (accumulator, currentValue) => accumulator + currentValue,
      0,
    );
    // 计算百分比进度
    progress.value = Math.min((curTotal / totalSize).toFixed(2), 100)
  }
};

// 合并请求
const mergeChunkRequest = (filename, fileHash) => {
      
      
  return lcRequest.post({
      
      
      url: '/api/merge',
      data: {
      
       filename, hash: fileHash }
    })
}

// 文件类型限制
const fileTypeCheck = (file, typesArr) => {
      
      
  const index = file.name.lastIndexOf('.')
  const ext = file.name.slice(index + 1)

  if (!typesArr.includes(ext)) {
      
      
    alert(`${ 
        ext} 文件不允许上传!`)
    return false
  }
  return true
} 

// 获取文件对象
const handleFileChange = e => {
      
      
  const file = e.target.files[0]
  const isLegalType = fileTypeCheck(file.name, fileType)
  // 将每次选择的文件添加到待上传数组中
  isLegalType || fileList.value.push({
      
       file, preUrl: URL.createObjectURL(file) })
}

// 启动 worker 线程
const worker = new Worker('/src/utils/worker.js')

// 秒传
const verifyUpload = (filename, hash) => {
      
      
  // return { uploadedSignal: false, uploadedList: [] } // 强制从0开始上传
  return lcRequest.post({
      
      
    url: '/verify',
    data: {
      
       filename, hash },
    headers: {
      
       'Content-Type': 'application/json' },
  })
}
  
// 断点续传
const resumeUpload = (uploadedList, chunkList) => {
      
      

  const uploadedIndexList = uploadedList.map(item => {
      
      
    return item.match(/-(\d+)\./)
  })
  const resumeChunkList = chunkList.filter(item => {
      
      
    return !uploadedIndexList.includes(item.currentChunk)
  })
  return {
      
       uploadedIndexList, resumeChunkList }
}

// 分片数组并发上传
const chunkListUpload = (chunkList, uploadChunk, fileHash, file, concurrentControlFn, limit = 3) => {
      
      
  // 构建请求数组
  const requestsArr = []
  for (let index = 0; index < chunkList.length; index++) {
      
      
    requestsArr[index] = () => uploadChunk(chunkList[index], fileHash, file.name, file.size)
  }
  return concurrentControlFn(requestsArr, limit)
}

// 点击上传
const handleClickUpload = async () => {
      
      
  // 测试的是大文件单文件上传,如果是多文件,遍历文件数组处理即可
  const file = fileList.value[0].file
  console.log(file);

  worker.postMessage({
      
       file: file, DefualtChunkSize: chunkSize })
  
  worker.onmessage = async e => {
      
      
    const {
      
       chunkList, fileHash } = e.data

    // 1. 秒传验证
    const {
      
       uploadedSignal, uploadedList } = await verifyUpload(file.name, fileHash)
    if (uploadedSignal === true) {
      
      
      progress.value = 100 // 秒传,进度条百分之百
    } else if (uploadedSignal === false && uploadedList.length === 0) {
      
      
      // 2. 从0上传
      const chunkListUploadRes = await chunkListUpload(chunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
      console.log(chunkListUploadRes);
      const res = await mergeChunkRequest(file.name, fileHash)
      console.log(res);
    } else {
      
      
      // 3. 断点续传
      const {
      
       uploadedIndexList, resumeChunkList } = resumeUpload(uploadedList, chunkList)
      // 先填入 progressArr
      uploadedIndexList.forEach(index => {
      
      
        progressArr[index] = chunkSize
      });
      
      const chunkListUploadRes = await chunkListUpload(resumeChunkList, uploadChunk, fileHash, file, controlConcurrency, 2)
      console.log(chunkListUploadRes);
      const res = await mergeChunkRequest(file.name, fileHash)
      console.log(res);
    }
  }
}

// 取消请求
const handleClickAbortUpload = () => {
      
      
  controller.abort()
  alert("请求取消")
}

</script>
<style scoped>
.upload-box {
      
      
  width: 600px;
  padding: 0 10px;
  border: 1px solid;
  border-radius: 10px;
}

.head {
      
      
  width: 100%;
  height: 50px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid;
}

.preview-box {
      
      
	height: 500px;
}

.files-info {
      
      
  display: flex;
  justify-content: space-evenly;
  flex-flow: row wrap;
  overflow: auto;
}

.card .name {
      
      
  padding: 4px 0;
  text-align: center;
}

.result-box {
      
      
  height: 200px;
  border-bottom: 1px solid;
}

.result-box .result {
      
      
  display: flex;
  flex-flow: column;
  justify-content: space-evenly;
}

.action {
      
      
  height: 50px;
  display: flex;
  justify-content: space-around;
  align-items: center;
}

img,
video {
      
      
  width: 100px;
  object-fit: contain;
}

/* 进度条 */
.progress {
      
      
  height: 10px;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.progress .background {
      
      
  height: 4px;
  width: 200px;
  border: 1px solid;
  border-radius: 10px;
}

.progress .background .foreground {
      
      
  height: 100%;
  background-color: pink;
}
</style>

Supongo que te gusta

Origin blog.csdn.net/qq_43220213/article/details/130120575
Recomendado
Clasificación