一步一步,实现一个完备的文件分片上传项目

160 阅读10分钟

为什么我们需要分片上传❓

在日常的前端开发中,文件上传的相关的开发任务不算司空见惯,也算是家常便饭了。应用注册的时候上传一个头像,发帖的时候贴上图片,视频,GIF图...这些都应用到了文件上传。

放在平时,把上传的File对象塞进FormData中,再调用一下API就可以美滋滋的下班啦!可是某天,项目中有了一个新需求:做一个文件中转站!如果天真的你还用刚才的方法。好啦,面对无尽的loading界面,不仅用户等的急跺脚,后端也会因为内存爆掉跳起来打你的膝盖。于是你一咬牙,爷不干了。

阅读本篇文章,你将学到👇

好啦,为了窝囊废,你下定决心,前后端我全给撸了。于是你开始分析如何上传一个大一点的文件呢?!

核心就在于:将文件分片上传到后端,再合并成一个完整的文件。这里还有一个问题,如何保证文件完整呢?答案就是哈希,计算文件的哈希值就可以辨别和验证一个文件的完整性。

确定了这个宗旨,再分析实现流程。

  1. 文件分片:将文件切成固定大小的块,这就是文件分片了。考虑将切片直接保存到indexedDB中,下次可以直接根据文件名获取文件切片
  2. 计算文件的哈希:使用sparkMD5计算文件的哈希。这个计算比较费事,考虑使用Web Worker避免阻塞主线程
  3. 检查服务器是否存在文件:如果服务器已经存在文件了,直接告诉用户上传成功就好了啊。恭喜你,解锁了秒传到奥秘
  4. 上传切片:上传切片依旧遵循 检查-上传 的步骤。先验证这个切片是否在服务端上已经存在,如果存在就无需上传了。这样即使用户意外中断了上传进程也避免了浪费带宽重新上传。考虑到上传数量巨大,可以使用并发池保证同时请求数量控制在合理范围内,如果并发池加上暂停功能就更好了
  5. 合并文件:分片上传完成后就可以通知服务端合并分片成一个文件了

你考虑的很仔细,分析的同时已经考虑好如何针对每一步优化了。

Step1,2 文件分片 + 计算文件哈希

首先看到的就是一个文件对象IndexedBDStorage,负责文件切片的存取。在本地的IndexedDB获取不到文件切片的时候就需要我们计算好文件哈希和切片存进去啦!

let fileChunks: FilePiece[]
let hash: string
const filename = file.name
const fs: FileStorage<string, StorageFilePieces> = new IndexedDBStorage<
  string,
  StorageFilePieces
>('bigFile', 'FileChunk', 'filename', 'filename')

if (await fs.isExist(filename)) {
  // 切片如果已经存在,直接取出
  ;({ fileChunks, hash } = await fs.get(filename))
} else {
  // 切片不存在,计算
  fileChunks = splitFile(file)
  hash = await calcHash({
    chunks: fileChunks
  })
  await fs.save(filename, {
    fileChunks,
    hash
  })
}

那么,如何进行文件切片呢?这里我们应用到了File.slice方法,它允许我们获取文件的一部分,splitFile方法就是截取文件片段,然后保存到数组。

export function splitFile(
  file: File,
  chunkSize: number = CHUNK_SIZE
): FilePiece[] {
  const fileChunks: FilePiece[] = []
  for (let i: number = 0; i < file.size; i += chunkSize) {
    const chunk: Blob = file.slice(i, i + chunkSize)
    fileChunks.push({
      chunk,
      size: chunk.size
    })
  }
  return fileChunks
}

分片完成后就应该计算文件的哈希了,他是我们分辨文件重要的身份标识。计算文件的哈希依赖sparkMD5这个库,它需要将分片文件转换成ArrayBuffer,再append进spark中,最后调用end就可以获取文件的哈希了刚好就应用到了分好的切片。如果不希望这个繁重的任务拖垮主线程,那就试试在Web Worker中完成吧。

import { type FilePiece } from '@/utils/file'
import SparkMD5 from 'spark-md5'

onmessage = async (e: MessageEvent): Promise<void> => {
  const spark: SparkMD5.ArrayBuffer = new SparkMD5.ArrayBuffer()
  const chunks: FilePiece[] = e.data

  let cur: number = 0
  while (cur < chunks?.length) {
    const chunk: FilePiece = chunks[cur]
    spark.append(await readAsArrayBuffer(chunk.chunk))
    const percentage: number = (cur + 1) / chunks.length
    postMessage({
      percentage,
      hash: spark.end()
    })
    ++cur
  }
  /**
   * 关闭worker
   */
  self.close()
}

export async function readAsArrayBuffer(file: Blob): Promise<ArrayBuffer> {
  const fileReader: FileReader = new FileReader()
  fileReader.readAsArrayBuffer(file)
  return new Promise((resolve: (buffer: ArrayBuffer) => void): void => {
    fileReader.onload = (e: ProgressEvent<FileReader>): void => {
      resolve(e.target?.result as ArrayBuffer)
    }
  })
}

可以忽略的部分 IndexedDBStorage的实现

这个项目中对IndexedDB的封装主要是为了不重复计算文件哈希和分片,但是增加了内存压力,所以可以选择性忽略,但是封装还是值得一试

import FileStorage from '@/utils/FileStorage'

export default class IndexedDBStorage<K, T> extends FileStorage<K, T> {
  private request: IDBOpenDBRequest | undefined
  private db: IDBDatabase | undefined
  private readonly dbName: string
  private readonly storeName: string
  private readonly key: string
  private readonly keyPath: string

  constructor(dbName: string, storeName: string, key: string, keyPath: string) {
    super()
    this.dbName = dbName
    this.storeName = storeName
    this.key = key
    this.keyPath = keyPath
  }

  // 连接数据库
  private async connect(): Promise<boolean> {
    if (this.db) {
      return true
    }
    return new Promise(
      (
        resolve: (opened: boolean) => void,
        reject: (reason: DOMException | Event | null) => void
      ) => {
        this.request = indexedDB.open(this.dbName, 1)
        this.request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
          this.db = (e.target as IDBOpenDBRequest).result
          // 创建objectStore对象,keyPath是主键
          const objectStore: IDBObjectStore = this.db.createObjectStore(
            this.storeName,
            {
              keyPath: this.keyPath
            }
          )
          // 创建索引
          objectStore.createIndex(this.key, this.keyPath, { unique: true })
          console.log('创建数据库成功')
        }
        this.request.onerror = (e: Event) => {
          /**
           * 优化:
           * 错误的时候reject
           */
          console.error('创建数据库失败')
          reject(e)
        }
        this.request.onsuccess = (e: Event) => {
          console.log('打开数据库成功')
          this.db = (e.target as IDBOpenDBRequest).result
          resolve(true)
        }
      }
    )
  }

  // 读取数据
  public async get(key: K): Promise<T> {
    const isConnect: boolean = await this.connect()
    if (!isConnect) {
      return undefined!
    }
    return new Promise(
      (
        resolve: (data: T) => void,
        reject: (reason: DOMException | null) => void
      ) => {
        const request: IDBRequest = this.db!.transaction(
          this.storeName,
          'readonly'
        )
          .objectStore(this.storeName)
          .get(String(key))

        request.onsuccess = (e: Event): void => {
          console.log('哈希查询结果:', request.result)
          if (request.result) {
            resolve(request.result.value)
          }
          resolve(undefined!)
        }
        request.onerror = (e: Event) => {
          console.log('查询事物失败:', request.error)
          reject(request.error)
        }
      }
    )
  }

  // 通过哈希查询数据是否存在
  public async isExist(key: K): Promise<boolean> {
    const isConnect: boolean = await this.connect()
    if (!isConnect) {
      return false
    }
    return new Promise(
      (
        resolve: (isExist: boolean) => void,
        reject: (reason: DOMException | null) => void
      ): void => {
        const request: IDBRequest = this.db!.transaction(
          this.storeName,
          'readonly'
        )
          .objectStore(this.storeName)
          .get(String(key))

        request.onsuccess = (e: Event): void => {
          console.log('哈希查询结果:', request.result)
          if (request.result) {
            resolve(true)
          }
          resolve(false)
        }
        request.onerror = (e: Event) => {
          console.log('查询事物失败:', request.error)
          reject(request.error)
        }
      }
    )
  }

  public async save(key: K, value: T): Promise<void> {
    const isConnect: boolean = await this.connect()
    if (!isConnect) {
      return
    }
    return new Promise(
      (
        resolve: () => void,
        reject: (reason: DOMException | null) => void
      ): void => {
        const request: IDBRequest = this.db!.transaction(
          this.storeName,
          'readwrite'
        )
          .objectStore(this.storeName)
          .add({
            [this.key]: key,
            value: value
          })
        request.onsuccess = (e: Event): void => {
          console.log('添加成功')
          resolve()
        }
        request.onerror = (e: Event): void => {
          console.log('添加事物失败:', request.error)
          reject(request.error)
        }
      }
    )
  }
}

Step3 检查服务器上文件是否存在

这一步就很简单啦,直接发个请求问问服务器文件是否存在就好啦。其实这里使用文件哈希去比对更好,也算是后续可以优化的点啦。

const {
  data: checkData,
  code: checkCode,
  message: checkMessage
} = await checkFileExists({
  name: file.name
})
if (checkCode !== 200) {
  setStatus('上传失败,' + checkMessage)
  return false
}
if (checkData.isExist) {
  setStatus('上传成功(秒传)')
  return true
}

Step4 上传切片

好了,到现在可以真正的开始上传文件了。一个大文件会产生很多切片,假设一个1GB大小的文件分片成4MB大小的块,将会产生256个切片。想象一下256个上传请求同时发出的情景~。虽然h2协议优化了http1.1上队头阻塞等问题,但也禁不住这么造啊。于是并发上传应运而生。

先实现一个异步并发

异步并发就是限制多个异步程序并发执行,且同时在执行的异步任务始终在限制范围内。市面上已经有很多成熟的异步库啦,比如大名鼎鼎的p-limit,如果不想实现可以直接使用,但是有什么比自己实现一个简单易懂的异步并发库更让人兴奋的呢。思路全在代码注释中,同时还实现了暂停和继续的功能

interface PromisePoolProps {
  limit: number
  onTick?: (percentage: number) => void
}

interface PromisePoolRes {
  state: 'fulfilled' | 'rejected'
  data: any
}

type Task = () => Promise<any>

/**
 * 总体思路:
 * 1. 首先为传进来的所有异步的任务执行一个run函数,这个run函数有如下功能
 * 2. 如果当前正在执行的任务小于等于并发限制,则run函数执行这个任务
 *    如果当前执行的任务超过并发限制,创建一个用于阻塞run函数的Promise,当这个Promise的resolve被消费时,对应的任务才会被执行。把这个resolve放入队列中
 *    初始情况下,有limit个任务并发执行,tasks.length-limit个任务被阻塞
 * 3. 当一个任务执行完成后,会检查resolve队列
 *    如果队列不为空,pop一个resolve出来并消费,此时这个resolve对应的任务会被执行
 *    如果队列为空,这所有的任务已经被执行
 *
 * 这是一个流程图:https://www.yuque.com/g/u1598738/ryg73d/uhy5c8rhlggtpu9g/collaborator/join?token=cm9N9pZZTGqbHlBq&source=doc_collaborator# 《并发池》
 *
 * 暂停与继续执行
 * 4. 暂停:在一个任务执行完成后检查resolve队列时,发现暂停的flag为true,则不消费队列中的resolve
 * 5. 继续执行:从队列中pop出limit-activeTask个resolve,并发的执行
 */
export default class PromisePool {
  private limit: number
  private activeTask: number
  /**
   * 这个queue存储的是创建的用于阻塞任务的resolve(run函数第一段)
   * 当resolve被消费的时候,任务执行
   */
  private queue: Array<() => void>
  private result: PromisePoolRes[]
  private pauseSignal: boolean
  private onTick?: (percentage: number) => void
  private taskNumber: number = 0

  constructor({ limit, onTick }: PromisePoolProps) {
    this.limit = limit
    this.queue = []
    this.activeTask = 0
    this.result = []
    this.pauseSignal = false
    this.onTick = onTick
  }

  async run(task: Task): Promise<void> {
    if (this.activeTask >= this.limit) {
      // 等待队列中的resolve被执行了才会执行task
      await new Promise<void>((resolve: () => void) => this.queue.push(resolve))
    }
    ++this.activeTask

    let res: any
    try {
      res = await task()
      this.result.push({
        state: 'fulfilled',
        data: res
      })
    } catch (e) {
      /**
       * 设置返回的结构PromisePoolRes
       */
      this.result.push({
        state: 'rejected',
        data: e
      })
    } finally {
      this.onTick && this.onTick(this.result.length / this.taskNumber)
      --this.activeTask
      if (this.queue.length && !this.pauseSignal) {
        /**
         * 这里从queue中pop一个resolve消费,被这个Promise阻塞的任务会执行
         */
        const resolve = this.queue.shift()!
        resolve()
      }
    }
  }

  async all(tasks: Task[]): Promise<PromisePoolRes[]> {
    if (Array.isArray(tasks) && tasks.length > 0) {
      this.taskNumber = tasks.length
      /**
       * 对每个任务执行run函数
       * 任务是否执行任务取决于当前活跃的任务activeTask是否超过限制limit
       * activeTask <= limit的时候,执行任务
       * active > limit的时候,阻塞任务,并把阻塞的resolve push进queue中
       * 执行完的任务会pop出一个resolve并消费,这个resolve阻塞的任务会执行
       */
      await Promise.all(tasks.map((task: Task) => this.run(task)))
    }
    return this.result
  }

  pause(): void {
    this.pauseSignal = true
  }

  continue(): void {
    this.pauseSignal = false
    /**
     * 这里应该pop出limit - activeTask个resolve消费
     */
    for (
      let i = 0;
      this.queue.length && i < this.limit - this.activeTask;
      ++i
    ) {
      const resolve = this.queue.shift()!
      resolve()
    }
  }

  getResult(): any[] {
    return this.result
  }
}

先检查后上传

有了并发库,就可以想办法创建并发任务了。我们依旧秉持先检查后上传的原则,已经上传过的切片就没必要上传了,这样程序意外中断后也可以继续上传啦!上传文件就很简单了,还是我们的老熟人FormData对象

/**
 * 上传切片请求函数,先检查后上传
 */
const pieceRequestHandler = async (
  fileChunk: FilePiece,
  hash: string,
  index: number
): Promise<boolean> => {
  const chunkName: string = `${hash}-$${index}`
  // 检查切片是否存在
  const { data: checkData, code: checkCode } = await checkFileExists({
    name: chunkName
  })
  if (checkCode !== 200) {
    return false
  }
  if (checkData.isExist) {
    return true
  }

  // 上传切片
  const formData = new FormData()
  formData.append('chunkName', chunkName)
  formData.append('chunk', fileChunk.chunk)
  const { code: uploadCode } = await uploadChunk(formData)
  return uploadCode === 200
}

并发上传,递归重传

pieceRequestHandler就是我们创建异步任务的工厂函数了,现在我们只需要排队将分片变成异步任务塞进并发池就好了。

面对可能出现的网络波动,我们还可以把上传失败的切片收集起来,再次push进并发池中重传,这里用递归的方法实现了重传

/**
 * 上传切片函数,递归重传
 */
const uploadChunks = async (
  fileChunks: FilePiece[],
  filename: string,
  retry: number = 0,
  hash: string
): Promise<boolean> => {
  const requests = fileChunks.map(
    (chunk: FilePiece, index: number) => () =>
      pieceRequestHandler(chunk, hash, index)
  )
  // 创建请求池,设置最大同时请求数
  const requestPool = new PromisePool({
    limit: 5,
    onTick: (percentage: number): void => {
      setCalcHashRatio(Number(percentage.toFixed(2)))
      setStatus(`文件上传中:${Math.floor(percentage * 100)}%`)
    }
  })
  setRequestPool(requestPool)
  const piecesUpload = await requestPool.all(requests)
  console.log('上传结果', piecesUpload)

  // 重传
  if (!piecesUpload.every((res) => res.state === 'fulfilled' && res.data)) {
    if (retry >= RETRY) {
      return false
    }
    console.log('重传次数:', retry)
    const retryHashChunks = fileChunks.filter(
      (undefined, index: number) => !piecesUpload[index]
    )
    return uploadChunks(retryHashChunks, filename, ++retry, hash)
  }
  return true
}

Step5 最后,合并

最后,所有的分片就已经上传到服务器,只要一声令下,让服务器合并上传的文件,我们的上传任务就算完成了!

这里我们几乎每一次上传前,都和服务器确认了一下文件是否已经存在,最大限度的减少带宽浪费。

const { code, message } = await mergeFile({
  name: file.name,
  hash,
  chunks: fileChunks.map((chunk, index) => ({
    index,
    size: chunk.size
  }))
})
if (code !== 200) {
  setStatus('上传失败,' + message)
  return false
}
setStatus('上传成功')
return true

至此,文件分片上传的前端部分就已经完成了,其中的React相关函数可以酌情处理,并不影响实现,其中还有一些辅助函数,不关键步骤没有贴上来,可以移步github查看完整实现

等等,还没完。后端实现呢

如果没有一个匹配的后端实现,看这些代码只能自嗨了。所以我们基于以上需求,你又用熟悉的nodejs实现了服务端的文件处理。需要的接口整理如下:

  1. 检查文件在服务端是否已经存在,这里最好使用哈希验证一个文件。使用文件名是我🧠被僵尸吃了
  2. 上传文件,分片文件我们采用大文件的哈希+分片索引的格式,方便以后按顺序合并
  3. 合并文件,这里是掉头发最多的地方,参考了多方无效的实现后最终还是用stream api实现了 接下来,一步一步看看里面的核心实现

检查文件存在

由于我们需要频繁的对文件系统操作,这里我们考虑把基本的文件操作封装一下,方便以后迁移到的文件系统,或者是数据库。

import * as fsPromises from 'node:fs/promises'
import * as path from 'node:path'
import { UPLOAD_FOLDER_PATH } from '@src/utils/constant'

interface LocalFileStorageParams {
  path: string
}

export default class LocalFileStorage {
  private readonly path: string
  public isInited: boolean

  constructor({ path }: LocalFileStorageParams) {
    this.path = path
    this.isInited = false
  }

  public async init() {
    await this.createDir()
    this.isInited = true
  }

  // 创建文件夹
  private async createDir(): Promise<void> {
    if (!(await this.pathExist(this.path))) {
      try {
        await fsPromises.mkdir(this.path)
        console.log('创建文件夹成功')
      } catch (e) {
        console.error('创建文件夹失败', e)
        throw e
      }
    }
  }

  public async isExist(filename: string): Promise<boolean> {
    return this.pathExist(path.resolve(this.path, filename))
  }

  public async save(filename: string, file: any): Promise<void> {
    try {
      const oldName = file.filepath
      const newName: string = path.resolve(UPLOAD_FOLDER_PATH, filename)
      await fsPromises.rename(oldName, newName)
    } catch (e) {
      console.error(`写入文件 ${filename} 失败:`, e)
      throw e
    }
  }

  public async pathExist(path: string): Promise<boolean> {
    try {
      await fsPromises.access(path)
      return true
    } catch {
      return false
    }
  }
}

有了这个类,我们在启动程序的时候初始化一下就可以很方便的实现验证文件存在的API了

try {
  const params: CheckFileExistsRequestParams = {
    name: ctx.request.query.name as string
  }
  const isExist = await ctx.localFs.isExist(params.name)
  ctx.body = {
    code: 200,
    message: 'success',
    data: {
      isExist
    }
  }
  ctx.status = 200
} catch {
  throw new HError(ErrorType.FileCheckExistError, '检查文件时发生错误')
}

上传文件

上传文件在类中使用save方法就可以了,核心使用到了koa-body,文件传输到后端时会保存到系统的临时文件夹中,我们直接移动到自己的文件夹中就可以了

try {
  const { chunkName }: { chunkName: string } = ctx.request.body
  await ctx.localFs.save(chunkName, ctx.request.files!.chunk)

  ctx.body = {
    code: 200,
    data: null,
    message: 'success'
  }
  ctx.status = 200
} catch {
  throw new HError(ErrorType.FileWriteError, '写入文件时发生错误')
}

合并文件

合并文件这里不想使用第三方库,又对nodejs的stream api不求甚解,是废头发最多的地方。一开始使用buffer实现的,考虑到文件全部读取到内存中服务器内存撑不住。于是改用stream api。记得合并好了后删除分片资源哦。

合并文件的时候按照约定的${params.hash}-$${chunk.index}名称取该文件的切片

// 合并文件
const mergeChunks = async (params: FileMergeRequestParams): Promise<void> => {
  const target: string = path.resolve(UPLOAD_FOLDER_PATH, params.name)
  const sources: MergeSource[] = params.chunks.map((chunk: ChunkInfo) => ({
    source: path.resolve(UPLOAD_FOLDER_PATH, `${params.hash}-$${chunk.index}`)
  }))

  const mergeFile: MergeFile = new MergeFile({
    target,
    sources
  })

  await mergeFile.merge()
}

MergeFile这个类不是很有必要,也可以直接取其方法。核心的方法是createReadStreamcreateWriteStreampipline

import { pipeline } from 'node:stream/promises'
import * as fs from 'node:fs'

export interface MergeSource {
  source: string
}

interface MergeFileParams {
  target: string
  sources: MergeSource[]
}

export default class MergeFile {
  private readonly target: string
  private readonly sources: MergeSource[]

  constructor({ target, sources }: MergeFileParams) {
    this.target = target
    this.sources = sources
  }

  public async merge(): Promise<void> {
    const writeStream: fs.WriteStream = fs.createWriteStream(this.target)
    writeStream.setMaxListeners(this.sources.length)

    try {
      for (const source of this.sources) {
        await pipeline(
          fs.createReadStream(source.source, { start: 0 }),
          writeStream,
          { end: false }
        )
      }
    } catch (e) {
      throw e
    } finally {
      writeStream.end()
    }
  }
}

后端的完整实现可以移步github。至此,文件切片上传的前后端就已经全部实现了!