揭秘大文件分片上传的终极技巧

401 阅读7分钟

学以致用,知行合一

前言

老生常谈的文件上传,在日常工作中,作为前端的我们,理解一个功能的整体流程是片面的,废话不多说,本文将从以下几个方面,带你尽可能的理解大文件上传的工作流程。

前端部分

  1. 分片上传
  2. 分片信息摘要
  3. 控制并发量
  4. 断点续传

后端部分

  1. 查询分片
  2. 上传分片
  3. 合并分片

技术栈: vue3 + ts + koa2

为什么需要分片上传及其优势

为什么?

想象一下,今天周五,领导让你将一个压缩包上传服务器,下周一开会用,压缩包10个G,你想都不想,直接去往服务器丢,离下班还有2个小时,时间是刚好够的,而在下班的前一刻,上传进度条99%,不出意外意外发生了,电脑死机,当场要提离职,还好同事把你拦住了,只能加班了。重新上传,从头再来。

而分片上传将大文件分成多个小片段,每次上传一个小片段,这样即使网络中断,只需要重新上传未完成的片段,而不必从头再来。

优势

除了可以避免以上情况外,还有哪些好处呢?

服务器端: 减轻服务器压力,一次性上传大文件会给服务器带来很大压力,甚至可能导致服务器崩溃。分片上传将文件分成若干小块逐一上传,服务器在处理这些小片段时压力会小很多。

业务处理时: 如果因为大量业务逻辑导致的代码宕机时,也可以最大程度的拯救你的上传进度,避免浪费不必要的时间。

知识储备

前端知识

  1. Js的File API (File 和 Blob 对象 包括读取文件、创建分片等)
  2. Promise和async/await(异步操作)
  3. 浏览器存储(IndexedDB,处理断点续传的核心)
  4. Web Workers(实现多线程处理,避免在主线程中执行耗时操作导致页面卡顿)

后端知识

  1. nodejs或者其他后端语言均可

实现

分片上传

将获取到的文件对象进行分片处理根据实际情况定义分片大小(chunkSize),如果拿到的是二进制流,或者其他格式,可以想办法转成文件对象。

export interface FilePiece {
  chunk: Blob
  size: number
}
// 分片的长度
const CHUNK_SIZE = 5 * 1024 * 1024
/**
 * @file 目标文件
 * @chunkSize 分片大小
 */
export const splitFile = (file: File, chunkSize = CHUNK_SIZE) => {
  const fileChunkList: FilePiece[] = []
  let len = 0
  while (len < file.size) {
    // 一个分片
    const piece = file.slice(len, len + chunkSize)
    fileChunkList.push({
      chunk: piece,
      size: piece.size,
    })
    len += chunkSize
  }
  return fileChunkList
}

分片信息摘要

因为javascript是单线程脚本语言,单线程会堵塞网络请求,上一个请求未结束时,后面的请求必须等待,所以这里选择使用worker实现多线程,并发进行分片的加密处理,worker的好处,让js代码多线程的工作模式,充分利用CPU的计算能力。

import { type FilePiece } from './file'
import SparkMD5 from 'spark-md5'
// 可以了解下worker的工作模式
self.onmessage = async (e) => {
  const fileChunkList = e.data as FilePiece[]
  // 进行加密处理
  const spark = new SparkMD5.ArrayBuffer()
  for (let i = 0; i < fileChunkList.length; i++) {
    const chunk = fileChunkList[i].chunk
    const res = await readChunk(chunk)
    spark.append(res as ArrayBuffer)
    // 发送进度消息
    self.postMessage({
      percentage: ((i + 1) / fileChunkList.length) * 100,
    })
  }
  self.postMessage({
    percentage: 100,
    hash: spark.end(),
  })

  self.close()
}

const readChunk = async (chunk: Blob) => {
  return new Promise((resolve, reject) => {
    const read = new FileReader()

    read.readAsArrayBuffer(chunk)
    read.onload = (e) => {
      if (e.target) {
        resolve(e.target.result)
      }
    }
  })
}

调用加密方法

//分片加密
export const createHash = ({
  chunks,
  onTick,
}: {
  chunks: FilePiece[]
  onTick?: (percentage: number) => void
}): Promise<string> => {
  return new Promise((resolve) => {
    // 开启多线程
    const worker = getWorker()
    worker.postMessage(chunks)
    worker.onmessage = (e) => {
      const { hash, percentage } = e.data
      onTick?.(parseInt(percentage.toFixed(2)))
      if (hash) {
        resolve(hash)
      }
    }
  })
}
//单例模式 全局只开启一次worker 防止资源浪费
let workerInstance: Worker | null = null

export const getWorker = (): Worker => {
  if (!workerInstance) {
    workerInstance = new Worker(new URL('./worker', import.meta.url), {
      type: 'module',
    })
  }
  return workerInstance
}

需要注意的是,为什么要做加密处理,加密是为了保证上传文件的唯一性,以便后续,做文件的秒传断点续传等工作,所以这一步至关重要。

控制并发量

实现高性能的大文件上传,这一步必不可少,制并发量,可以防止服务器因为同时处理太多请求而过载,帮助我们合理使用电脑和网络资源,不会因为一次上传太多文件块而导致系统变慢或崩溃。

const total = originChunks.length; //分片总数
const poolLimit = 5; // 并发限制数
let currentIndex = 0; // 当前上传的分片索引
let completed = 0; // 完成的分片数量
const pool: Promise<void>[] = [];

const doUpload = async (piece: FilePiece, index: number) => {
    const params = { hash, chunk: piece.chunk, index };
    try {
      ....
    } catch (error) {
      // 中断上传时候保存分片
      ...
      return;
    }
    ...
    if (completed === total) {
      // 合并文件
      ...
    }
    
    // 每当一个分片上传完成后,继续上传下一个分片
    if (currentIndex < total) {
      const nextPiece = originChunks[currentIndex];
      currentIndex += 1;
      pool.push(doUpload(nextPiece, currentIndex - 1).then(() => {
        // 从并发池中移除已完成的任务
        pool.splice(pool.findIndex(p => p === this), 1);
      }));
    }
  };
  // 初始化并发池
  while (currentIndex < poolLimit && currentIndex < total) {
    const piece = originChunks[currentIndex];
    currentIndex += 1;
    pool.push(doUpload(piece, currentIndex - 1).then(() => {
      // 从并发池中移除已完成的任务
      pool.splice(pool.findIndex(p => p === this), 1);
    }));
  }
  // 等待所有上传任务完成
  await Promise.all(pool);

断点续传

针对暂停、断网、停电、刷新页面等意外情况的处理,让用户不必重头开始上传文件,大概思路是拿到中断前分片的唯一标识,去服务端对比标识,从上次断开的位置开始上传。这里需要具备indexDB知识。 在控制并发量模块中的try/catch进行处理

// 利用浏览器自带的数据库,存放我们的分片信息
const DB_NAME = 'fileUploadDB';
const DB_VERSION = 1;
const STORE_NAME = 'chunks';

const openDB = () => {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    
    request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
      const db = (event.target as IDBOpenDBRequest).result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id' });
      }
    };
    
    request.onsuccess = (event: Event) => {
      resolve((event.target as IDBOpenDBRequest).result);
    };
    
    request.onerror = (event: Event) => {
      reject((event.target as IDBOpenDBRequest).error);
    };
  });
};

const saveChunkInfo = async (hash: string, index: number) => {
  const db = await openDB();
  const transaction = db.transaction(STORE_NAME, 'readwrite');
  const store = transaction.objectStore(STORE_NAME);
  store.put({ id: `${hash}-${index}`, hash, index });
};

const getUploadedChunks = async (hash: string) => {
  const db = await openDB();
  const transaction = db.transaction(STORE_NAME, 'readonly');
  const store = transaction.objectStore(STORE_NAME);
  const request = store.getAll();
  
  return new Promise<{ hash: string, index: number }[]>((resolve, reject) => {
    request.onsuccess = (event: Event) => {
      const result = (event.target as IDBRequest).result as { hash: string, index: number }[];
      resolve(result.filter(chunk => chunk.hash === hash));
    };
    request.onerror = (event: Event) => {
      reject((event.target as IDBRequest).error);
    };
  });
};

const clearChunks = async (hash: string) => {
  const db = await openDB();
  const transaction = db.transaction(STORE_NAME, 'readwrite');
  const store = transaction.objectStore(STORE_NAME);
  const request = store.getAll();
  
  request.onsuccess = (event: Event) => {
    const chunks = (event.target as IDBRequest).result as { hash: string, index: number }[];
    chunks.filter(chunk => chunk.hash === hash).forEach(chunk => {
      store.delete(chunk.id);
    });
  };
};

如何存 如何取

// 如何存
...
try {
      const { flag } = await findFile({ hash, index });
      if (!flag) {
        await uploadChunk({ ...params, cancelToken });
        // 每次上传完存
        await saveChunkInfo(hash, index);
      }
    } catch (error) {
      if (!navigator.onLine) {
        // 异常的时候存
        await saveChunkInfo(hash, index);
      }
      return;
    }
...
// 如何取
...
// 每次调用uploadChunks时 获取已上传的分片信息
const uploadedChunks = await getUploadedChunks(hash);
const uploadedIndexes = uploadedChunks.map(chunk => chunk.index);
completed = uploadedIndexes.length;
...

后端部分(koa),相对于前端部分,这部分更多是对文件接收、存储、校验和确认等工作

查询分片

import { type Context } from 'koa'
import { isExistFile } from '../storages/files'
import path from 'path'
import { UPLOAD_DIR } from '../const'

export const findFileController = (ctx: Context) => {
  let flag = false
  let hashDir = ''
  const { hash, index } = ctx.request.query

  const hashD = path.resolve(UPLOAD_DIR, hash as string)
  hashDir = path.resolve(hashD, index as string)
  flag = isExistFile(hashDir)

  ctx.body = {
    code: 0,
    flag: flag,
    message: '查询成功',
  }
  return
}

上传分片

import path from 'path'
import { type Context } from 'koa'
import { UPLOAD_DIR } from '../const'
import { readFile, writeFile, isExistFile, mkdir } from '../storages/files'

interface testFile {
  filepath?: string
}

export const uploadFileController = (ctx: Context) => {
  const { hash, index } = ctx.request.body
  const tempath = (ctx.request.files?.chunk as testFile)?.filepath

  if (!tempath) {
    throw console.error('文件地址无效!')
  }

  // 如果文件夾不存在 則創建
  const hashDir = path.resolve(UPLOAD_DIR, hash)
  if (!isExistFile(hashDir)) {
    mkdir(hashDir, true)
  }
  const filePath = path.resolve(hashDir, index)

  // 写入之前判断文件是否已经存在
  if (isExistFile(filePath)) {
    ctx.body = {
      code: 0,
      message: '该分片已经存在',
    }
    return
  }
  try {
    writeFile(filePath, readFile(tempath))

    ctx.body = {
      code: 0,
      message: '上传成功',
    }
  } catch (error) {
    ctx.body = {
      code: -1,
      message: '上传分片失败',
    }
    return
  }
}

合并分片

import { type Context } from 'koa'
import { readdir, appendFile, readFile } from 'fs/promises'
import { UPLOAD_DIR } from '../const'
import path from 'path'
import { isExistFile } from '../storages/files'

export const mergeFileController = async (ctx: Context) => {
  // 获取当前目录下的文件列表
  const { hash, filename } = ctx.request.query
  const hashDir = path.resolve(UPLOAD_DIR, hash as string)

  const flag = isExistFile(hashDir)

  if (!flag) {
    ctx.body = {
      code: 0,
      flag,
      message: '文件不存在!',
    }
  }

  try {
    // 读取目录中的文件
    const files = await readdir(hashDir)

    // 文件名转换为索引并排序
    const fn2idx = (filename: string) => +path.basename(filename)
    const sortedFiles = files.sort((r1, r2) => fn2idx(r1) - fn2idx(r2))

    // 读取所有文件的内容,使用 Promise.all 进行并行读取
    const fileReadPromises = sortedFiles.map((chunkPath) => {
      return readFile(path.join(hashDir, chunkPath))
    })

    // 等待所有文件读取完成
    const fileContents = await Promise.all(fileReadPromises)

    // 逐个将读取到的文件内容写入目标文件
    for (const chunkData of fileContents) {
      await appendFile(path.join(UPLOAD_DIR, filename as string), chunkData)
    }

    console.log('Files merged successfully!')
  } catch (error) {
    console.error('Error merging files:', error)
  }

  // 返回文件地址
  ctx.body = {
    code: 0,
    flag,
    message: '合并完毕',
  }
}

总结

大文件上传技术涉及前后端多个方面的知识,是一个复杂但非常实用的功能。分片上传通过将大文件切割成小片段逐一上传,解决了传统一次性上传方式中的网络不稳定、服务器压力大、上传失败需重传等问题。其优势在于提升上传效率、提高上传可靠性、减少资源浪费、实现大文件上传和提升用户体验等。