Напишите «готовую библиотеку загрузки фрагментов больших файлов» с нуля

внешний интерфейс программист внешний фреймворк

1. Пишите впереди

Я полагаю, что все вы сталкивались со сценой «загрузки больших файлов», когда занимались бизнесом. В этом сценарии мы не можем напрямую закидывать большие файлы на сервер для обработки, что окажет огромное влияние на производительность сервера, а скорость загрузки будет слишком низкой. Поэтому мы примем техническое решение «многочастная загрузка больших файлов», чтобы загружать файлы как можно быстрее и как можно меньше влиять на производительность сервера.

Совсем недавно я воспользовался своим свободным временем, чтобы узнать больше о технических деталях «загрузки больших файлов в несколько сегментов», и обнаружил, что опыт использования некоторых существующих библиотек загрузки сегментов не очень хорош, поэтому я написал большой файл файл с нуля здесь. Библиотека загрузки видео, одна из которых предназначена для углубления понимания, а другая - для облегчения прямого использования всеми в будущем.

2. Техническое решение для загрузки больших файлов по частям

Вообще говоря, загрузка больших файлов несколькими частями в основном включает следующие шаги:

1,Внешний расчетный файл md5. Целью вычисления md5 файла является проверка того, соответствует ли файл, загруженный на сервер, файлу, загруженному пользователем, и такие операции, как «вторая загрузка», также могут выполняться в соответствии с параметром md5.

2,Передняя часть отправляет запрос на инициализацию, а задняя часть инициализирует загрузку.. Когда md5 файла будет рассчитан, вы можете перейти к этапу инициализации загрузки.На этом этапе внешний интерфейс инициирует запрос инициализации, включая md5, имя файла и другую информацию, рассчитанную файлом, а серверная часть инициализировать полученные осколки в соответствии с каталогом файлов md5.

3.Внешний интерфейс выполняет сегментацию файлов и передает осколки на серверную часть.. Излишне говорить об этом шаге, интерфейс разделит файл на несколько небольших частей и загрузит их в соответствии с определенной стратегией.Если вы столкнулись с частью, которая не загружается, вам необходимо загрузить ее повторно.

4.Передняя часть отправляет запрос на загрузку, а задняя часть объединяет осколки.. Когда все фрагменты файла будут успешно отправлены, клиентская часть инициирует запрос на окончательную загрузку.После того, как серверная часть получит запрос, она объединит существующие фрагменты файла для создания файла и подтвердит, является ли md5 сгенерированного файла такой же, как тот, который был передан при инициализации.md5 такой же.

Стоит отметить, что когда файл относительно большой, «вычисление md5», «нарезка файлов» и «объединение файлов» непосредственно из файла потребляют много памяти (может даже напрямую заполнить память), поэтому в этих трех Этот шаг требует использования каналов для уменьшения потребления памяти.

3. простой загрузчик файлов

Давайте сначала опубликуем адрес «стандартной библиотеки загрузки больших файлов», написанной на Typescript:easy-file-uploader

Для конкретного использования вы можете напрямую щелкнуть указанный выше адрес, чтобы просмотреть README.md.

Итак, без лишних слов, давайте посмотрим, как я реализовал эту библиотеку.

Четыре, Easy-File-Uplesser-сервер реализации

Из «Технического решения для загрузки больших файлов частями» мы можем прояснить, что серверная часть должна в первую очередь предоставлять следующие основные возможности:

1. Инициализируйте загрузку файла 2. Получить фрагменты файлов 3. Объединить сегменты файла

Во-вторых, чтобы использовать опыт, нам также необходимо предоставить следующие дополнительные возможности:

4. Получите загруженную информацию о шарде 5. Очистите каталог хранилища сегментов (для отмены загрузки)

Поэтому сначала нужно написатьFileUploaderServerкласс, предоставляющий эти возможности. Таким образом, когда разработчик используетeasy-file-uploader-server, просто создайте экземплярFileUploaderServerкласс и использовать методы, предоставляемые этим классом в интерфейсе.

Это сделано для обеспечения лучшей масштабируемости — ведь разработчики могут реализовывать интерфейсы с такими фреймворками, как express/koa/native nodejs, если мы реализуем их один за другим. . . Слишком плохо для обслуживания.

Затем мы можем быстро написать большой фрейм этого класса, который выглядит так:

interface IFileUploaderOptions {
  tempFileLocation: string; // 分片存储路径
  mergedFileLocation: string; // 合并后的文件路径
}

class FileUploaderServer {
  private fileUploaderOptions: IFileUploaderOptions;
  
  /**
   * 初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹
   * @param fileName 文件名
   * @returns 上传Id
   */
  public async initFilePartUpload(fileName: string): Promise<string> {}

  /**
   * 上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为`partIndex|md5`
   * @param uploadId 上传Id
   * @param partIndex 分片序号
   * @param partFile 分片内容
   * @returns 分片md5
   */
  public async uploadPartFile(
    uploadId: string,
    partIndex: number,
    partFile: Buffer,
  ): Promise<string> {}

  /**
   * 获取已上传的分片信息,实际上就是读取这个文件夹下面的内容
   * @param uploadId 上传Id
   * @returns 已上传的分片信息
   */
  public async listUploadedPartFile(
    uploadId: string,
  ): Promise<IUploadPartInfo[]> {}

  /**
   * 取消文件上传,硬删除会直接删除文件夹,软删除会给文件夹改个名字
   * @param uploadId 上传Id
   * @param deleteFolder 是否直接删除文件夹
   */
  async cancelFilePartUpload(
    uploadId: string,
    deleteFolder: boolean = false,
  ): Promise<void> {}

  /**
   * 完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
   * @param uploadId 上传Id
   * @param fileName 文件名
   * @param md5 文件md5
   * @returns 文件存储路径
   */
  async finishFilePartUpload(
    uploadId: string,
    fileName: string,
    md5: string,
  ): Promise<IMergedFileInfo> {}
}

4-1. Инициализация загрузки файла

При инициализации загрузки нам нужноtempFileLocationСоздайте новый каталог на основе md5 в каталоге (то есть каталоге хранения фрагментов), чтобы сохранить загруженные фрагменты. Имя этого каталога — uploadId, основанное на${fileName}-${Date.now()}Расчетное значение md5.

  /**
   * 初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹
   * @param fileName 文件名
   * @returns 上传Id
   */
  public async initFilePartUpload(fileName: string): Promise<string> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadId = calculateMd5(`${fileName}-${Date.now()}`);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (uploadFolderExist) {
      throw new FolderExistException(
        'found same upload folder, maybe you meet hash collision',
      );
    }
    await fse.mkdir(uploadFolderPath);
    return uploadId;
  }

4-2 Получение фрагментов файла

При получении фрагментов файлов мы сначала получаем место хранения фрагмента, затем вычисляем md5 фрагмента, а затем именуем фрагмент как${partIndex}|${partFileMd5}.partи сохраните его по соответствующему пути.

/**
   * 上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为`partIndex|md5`
   * @param uploadId 上传Id
   * @param partIndex 分片序号
   * @param partFile 分片内容
   * @returns 分片md5
   */
  public async uploadPartFile(
    uploadId: string,
    partIndex: number,
    partFile: Buffer,
  ): Promise<string> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    const partFileMd5 = calculateMd5(partFile);
    const partFileLocation = path.join(
      uploadFolderPath,
      `${partIndex}|${partFileMd5}.part`,
    );
    await fse.writeFile(partFileLocation, partFile);
    return partFileMd5;
  }

4-3.Объединить сегменты файла

При объединении фрагментов файлов самое главное следующееmergePartFileметод, который будет использоватьreadStreamа такжеwriteStreamПреимущество чтения/записи фрагментов файлов заключается в том, чтобы максимально сократить использование памяти. использовать одновременноMultiStreamкоторый предоставилpipeметод, гарантирующий порядок потока.

export async function mergePartFile(
  files: IFileInfo[],
  mergedFilePath: string,
): Promise<void> {
  const fileList = files.map((item) => {
    const [index] = item.name.replace(/\.part$/, '').split('|');
    return {
      index: parseInt(index),
      path: item.path,
    };
  });
  const sortedFileList = fileList.sort((a, b) => {
    return a.index - b.index;
  });
  const sortedFilePathList = sortedFileList.map((item) => item.path);
  merge(sortedFilePathList, mergedFilePath);
}

function merge(inputPathList: string[], outputPath: string) {
  const fd = fse.openSync(outputPath, 'w+');
  const writeStream = fse.createWriteStream(outputPath);
  const readStreamList = inputPathList.map((path) => {
    return fse.createReadStream(path);
  });
  return new Promise((resolve, reject) => {
    const multiStream = new MultiStream(readStreamList);
    multiStream.pipe(writeStream);
    multiStream.on('end', () => {
      fse.closeSync(fd);
      resolve(true);
    });
    multiStream.on('error', () => {
      fse.closeSync(fd);
      reject(false);
    });
  });
}

тогда естьmergePartFileметод, объедините осколки файловfinishFilePartUploadМетод также готов появиться, вmergePartFileНа основе можно добавить получение пути сохранения файла и проверку md5.

  /**
   * 完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
   * @param uploadId 上传Id
   * @param fileName 文件名
   * @param md5 文件md5
   * @returns 文件存储路径
   */
  async finishFilePartUpload(
    uploadId: string,
    fileName: string,
    md5: string,
  ): Promise<IMergedFileInfo> {
    const { mergedFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(mergedFileLocation);
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    const dirList = await listDir(uploadFolderPath);
    const files = dirList.filter((item) => item.path.endsWith('.part'));
    const mergedFileDirLocation = path.join(mergedFileLocation, md5);
    await fse.ensureDir(mergedFileDirLocation);
    const mergedFilePath = path.join(mergedFileDirLocation, fileName);
    await mergePartFile(files, mergedFilePath);
    await wait(1000); // 要等待一段时间,否则在计算md5时会读取到空文件
    const mergedFileMd5 = await calculateFileMd5(mergedFilePath);
    if (mergedFileMd5 !== md5) {
      throw new Md5Exception('md5 checked failed');
    }
    return {
      path: mergedFilePath,
      md5,
    };
  }

4-4 Получить информацию о загруженном фрагменте

Получение информации о загруженном фрагменте — это фактически чтение всех файлов фрагментов с суффиксной частью в каталоге uploadId Логика очень проста, просто посмотрите на код.

  /**
   * 获取已上传的分片信息,实际上就是读取这个文件夹下面的内容
   * @param uploadId 上传Id
   * @returns 已上传的分片信息
   */
  public async listUploadedPartFile(
    uploadId: string,
  ): Promise<IUploadPartInfo[]> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    const dirList = await listDir(uploadFolderPath);
    const uploadPartInfo = dirList.map((item: IFileInfo) => {
      const [index, md5] = item.name.replace(/\.part$/, '').split('|');
      return {
        path: item.path,
        index: parseInt(index),
        md5,
      };
    });
    return uploadPartInfo;
  }

function listDir(path: string): Promise<IFileInfo[]> {
  const items = await fse.readdir(path);
  return Promise.all(
    items
      .filter((item: string) => !item.startsWith('.'))
      .map(async (item: string) => {
        return {
          name: item,
          path: `${path}/${item}`,
        };
      }),
  );
}

4-5. Очистите каталог хранилища сегментов

На самом деле очень просто очистить каталог хранилища сегментов.Если это обратимое удаление, вы можете напрямую изменить имя каталога. Если это жесткое удаление, удалите каталог.

  /**
   * 取消文件上传,硬删除会直接删除文件夹,软删除会给文件夹改个名字
   * @param uploadId 上传Id
   * @param deleteFolder 是否直接删除文件夹
   */
  async cancelFilePartUpload(
    uploadId: string,
    deleteFolder: boolean = false,
  ): Promise<void> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    if (deleteFolder) {
      await fse.remove(uploadFolderPath);
    } else {
      await fse.rename(uploadFolderPath, `${uploadFolderPath}[removed]`);
    }
  }

4-6.Подробный код

Затем соедините приведенный выше код вместе,FileUploaderServerКласс завершен. Более подробный код можно просмотреть по указанному выше адресу github:кликните сюда

import * as path from 'path';
import * as fse from 'fs-extra';
import {
  calculateFileMd5,
  calculateMd5,
  IFileInfo,
  listDir,
  mergePartFile,
  wait,
} from './util';
import {
  FolderExistException,
  Md5Exception,
  NotFoundException,
} from './exception';

const DEAFULT_TEMP_FILE_LOCATION = path.join(__dirname, './upload_file');
const DEAFULT_MERGED_FILE_LOCATION = path.join(__dirname, './merged_file');
const DEFAULT_OPTIONS = {
  tempFileLocation: DEAFULT_TEMP_FILE_LOCATION,
  mergedFileLocation: DEAFULT_MERGED_FILE_LOCATION,
};

export interface IFileUploaderOptions {
  tempFileLocation: string;
  mergedFileLocation: string;
}

export interface IUploadPartInfo {
  path: string;
  index: number;
  md5: string;
}

export interface IMergedFileInfo {
  path: string;
  md5: string;
}

export class FileUploaderServer {
  private fileUploaderOptions: IFileUploaderOptions;

  constructor(options: IFileUploaderOptions) {
    this.fileUploaderOptions = Object.assign(DEFAULT_OPTIONS, options);
  }

  public getOptions() {
    return this.fileUploaderOptions;
  }

  /**
   * 初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹
   * @param fileName 文件名
   * @returns 上传Id
   */
  public async initFilePartUpload(fileName: string): Promise<string> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadId = calculateMd5(`${fileName}-${Date.now()}`);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (uploadFolderExist) {
      throw new FolderExistException(
        'found same upload folder, maybe you meet hash collision',
      );
    }
    await fse.mkdir(uploadFolderPath);
    return uploadId;
  }

  /**
   * 上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为`partIndex|md5`
   * @param uploadId 上传Id
   * @param partIndex 分片序号
   * @param partFile 分片内容
   * @returns 分片md5
   */
  public async uploadPartFile(
    uploadId: string,
    partIndex: number,
    partFile: Buffer,
  ): Promise<string> {
    const uploadFolderPath = await this.getUploadFolder(uploadId);
    const partFileMd5 = calculateMd5(partFile);
    const partFileLocation = path.join(
      uploadFolderPath,
      `${partIndex}|${partFileMd5}.part`,
    );
    await fse.writeFile(partFileLocation, partFile);
    return partFileMd5;
  }

  /**
   * 获取已上传的分片信息,实际上就是读取这个文件夹下面的内容
   * @param uploadId 上传Id
   * @returns 已上传的分片信息
   */
  public async listUploadedPartFile(
    uploadId: string,
  ): Promise<IUploadPartInfo[]> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    const dirList = await listDir(uploadFolderPath);
    const uploadPartInfo = dirList.map((item: IFileInfo) => {
      const [index, md5] = item.name.replace(/\.part$/, '').split('|');
      return {
        path: item.path,
        index: parseInt(index),
        md5,
      };
    });
    return uploadPartInfo;
  }

  /**
   * 取消文件上传,硬删除会直接删除文件夹,软删除会给文件夹改个名字
   * @param uploadId 上传Id
   * @param deleteFolder 是否直接删除文件夹
   */
  async cancelFilePartUpload(
    uploadId: string,
    deleteFolder: boolean = false,
  ): Promise<void> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    if (deleteFolder) {
      await fse.remove(uploadFolderPath);
    } else {
      await fse.rename(uploadFolderPath, `${uploadFolderPath}[removed]`);
    }
  }

  /**
   * 完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
   * @param uploadId 上传Id
   * @param fileName 文件名
   * @param md5 文件md5
   * @returns 文件存储路径
   */
  async finishFilePartUpload(
    uploadId: string,
    fileName: string,
    md5: string,
  ): Promise<IMergedFileInfo> {
    const { mergedFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(mergedFileLocation);
    const uploadFolderPath = await this.getUploadFolder(uploadId);
    const dirList = await listDir(uploadFolderPath);
    const files = dirList.filter((item) => item.path.endsWith('.part'));
    const mergedFileDirLocation = path.join(mergedFileLocation, md5);
    await fse.ensureDir(mergedFileDirLocation);
    const mergedFilePath = path.join(mergedFileDirLocation, fileName);
    await mergePartFile(files, mergedFilePath);
    await wait(1000); // 要等待一段时间,否则在计算md5时会读取到空文件
    const mergedFileMd5 = await calculateFileMd5(mergedFilePath);
    if (mergedFileMd5 !== md5) {
      throw new Md5Exception('md5 checked failed');
    }
    return {
      path: mergedFilePath,
      md5,
    };
  }

  /**
   * 获取上传文件夹的路径
   * @param uploadId 上传Id
   * @returns 文件夹路径
   */
  private async getUploadFolder(uploadId: string): Promise<string> {
    const { tempFileLocation } = this.fileUploaderOptions;
    await fse.ensureDir(tempFileLocation);
    const uploadFolderPath = path.join(tempFileLocation, uploadId);
    const uploadFolderExist = fse.existsSync(uploadFolderPath);
    if (!uploadFolderExist) {
      throw new NotFoundException('not found upload folder');
    }
    return uploadFolderPath;
  }
}

Пять, простой процесс реализации клиентского загрузчика файлов

После написания внутренней логики мы можем приступить к написанию внешней логики.

Как упоминалось выше, чтобы удовлетворить потребности разработчиков в масштабируемости,easy-file-uploader-serverОн предоставляет возможность «загружать большие файлы по частям», а не напрямую предоставлять интерфейс для «загрузки больших файлов по частям». Это приводит к проектированиюeasy-file-uploader-clientКогда запрос не может быть сделан напрямую. следовательно,easy-file-uploader-clientВ начале функции проектирования ожидается только обеспечение контроля над процессом загрузки фрагмента, но не реализация конкретной функции загрузки.

Затем таким образом,easy-file-uploader-clientНеобходимы следующие основные способности:

1. Расчет и фрагментация файла md5 2. Поддержка пользовательских функций загрузки и управление процессом выполнения этих функций загрузки.

Поэтому сначала нужно написатьFileUploaderClientкласс, предоставляющий эти возможности. Таким образом, когда разработчик используетeasy-file-uploader-client, просто создайте экземплярFileUploaderClientclass и использовать предоставленные возможности при загрузке. Конечно, если пользователь хочет сам контролировать процесс выполнения функции загрузки, он может использовать только возможность «расчёта и фрагментации файла md5».

5-1.Вычисление и фрагментация файла md5

Здесь используется реализация функции расчета файла md5spark-md5для вычисления значения md5 файла. Для фрагментации используйте собственный браузерFileReaderПрочтите файл, а затем используйте API, поставляемый с браузером.blobSliceк осколку.

Здесь стоит отметить, что при большом размере файла «непосредственное вычисление md5 для всего файла» и «непосредственная загрузка всего файла и нарезка» — это операции, требующие высокой производительности и занимающие много времени. В этом случае нам также понадобитсяeasy-file-uploader-serverТочно так же файл читается через входной поток.

  /**
   * 将file对象进行分片,然后根据分片计算md5
   * @param file 要上传的文件
   * @returns 返回md5和分片列表
   */
  public async getChunkListAndFileMd5(
    file: File,
  ): Promise<{ md5: string; chunkList: Blob[] }> {
    return new Promise((resolve, reject) => {
      let currentChunk = 0;
      const chunkSize = this.fileUploaderClientOptions.chunkSize;
      const chunks = Math.ceil(file.size / chunkSize);
      const spark = new SparkMD5.ArrayBuffer();
      const fileReader = new FileReader();
      const blobSlice = getBlobSlice();
      const chunkList: Blob[] = [];

      fileReader.onload = function (e) {
        if (e?.target?.result instanceof ArrayBuffer) {
          spark.append(e.target.result);
        }
        currentChunk++;

        if (currentChunk < chunks) {
          loadNextChunk();
        } else {
          const computedHash = spark.end();
          resolve({ md5: computedHash, chunkList });
        }
      };

      fileReader.onerror = function (e) {
        console.warn('read file error', e);
        reject(e);
      };

      function loadNextChunk() {
        const start = currentChunk * chunkSize;
        const end =
          start + chunkSize >= file.size ? file.size : start + chunkSize;

        const chunk = blobSlice.call(file, start, end);
        chunkList.push(chunk);
        fileReader.readAsArrayBuffer(chunk);
      }

      loadNextChunk();
    });
  }

5-2. Управление процессом загрузки

Для контроля процесса загрузки на самом деле относительно простой, прежде всего, нам нужны разработчики для реализации своих собственныхinitFilePartUploadFunc,uploadPartFileFunc,finishFilePartUploadFuncтри функции, а затем передать их как элементы конфигурацииFileUploaderClient. Наконец, мы предоставляем еще одинuploadFileфункцию, и выполните по очереди три функции в элементе конфигурации, чтобы завершить весь процесс загрузки больших файлов по сегментам.

Общий процесс загрузки на самом деле относительно прост: 1. ВыполнитьgetChunkListAndFileMd5, разбить файл и вычислить md5. 2. ВыполнитьinitFilePartUploadFunc, чтобы инициализировать загрузку файла. 3. Выполните один раз для каждого осколкаuploadPartFileFunc, и если это не удается, добавьте его вretryList. 4. ДаretryListПовторите попытку фрагментов, которые не удалось загрузить в . 5. ВыполнитьfinishFilePartUploadFuncчтобы завершить загрузку файла.

  /**
   * 上传文件方法,当FileUploaderClient的配置项中传入了requestOptions才能使用
   * 会依次执行getChunkListAndFileMd5、配置项中的initFilePartUploadFunc、配置项中的uploadPartFileFunc、配置项中的finishFilePartUploadFunc
   * 执行完成后返回上传结果,若有分片上传失败,则会自动重试
   * @param file 要上传的文件
   * @returns finishFilePartUploadFunc函数Promise resolve的值
   */
  public async uploadFile(file: File): Promise<any> {
    const requestOptions = this.fileUploaderClientOptions.requestOptions;
    const { md5, chunkList } = await this.getChunkListAndFileMd5(file);
    const retryList = [];

    if (
      requestOptions?.retryTimes === undefined ||
      !requestOptions?.initFilePartUploadFunc ||
      !requestOptions?.uploadPartFileFunc ||
      !requestOptions?.finishFilePartUploadFunc
    ) {
      throw Error(
        'invalid request options, need retryTimes, initFilePartUploadFunc, uploadPartFileFunc and finishFilePartUploadFunc',
      );
    }

    await requestOptions.initFilePartUploadFunc();

    for (let index = 0; index < chunkList.length; index++) {
      try {
        await requestOptions.uploadPartFileFunc(chunkList[index], index);
      } catch (e) {
        console.warn(`${index} part upload failed`);
        retryList.push(index);
      }
    }

    for (let retry = 0; retry < requestOptions.retryTimes; retry++) {
      if (retryList.length > 0) {
        console.log(`retry start, times: ${retry}`);
        for (let a = 0; a < retryList.length; a++) {
          const blobIndex = retryList[a];
          try {
            await requestOptions.uploadPartFileFunc(
              chunkList[blobIndex],
              blobIndex,
            );
            retryList.splice(a, 1);
          } catch (e) {
            console.warn(
              `${blobIndex} part retry upload failed, times: ${retry}`,
            );
          }
        }
      }
    }

    if (retryList.length === 0) {
      return await requestOptions.finishFilePartUploadFunc(md5);
    } else {
      throw Error(
        `upload failed, some chunks upload failed: ${JSON.stringify(
          retryList,
        )}`,
      );
    }
  }

5-3 Общий код

Если объединить приведенный выше код, класс FileUploaderClient готов. Более подробный код можно просмотреть по указанному выше адресу github:кликните сюда

import SparkMD5 from 'spark-md5';
import { getBlobSlice } from './util';

const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const DEFAULT_OPTIONS = {
  chunkSize: DEFAULT_CHUNK_SIZE,
};

export interface IFileUploaderClientOptions {
  chunkSize: number;
  requestOptions?: {
    retryTimes: number;
    initFilePartUploadFunc: () => Promise<any>;
    uploadPartFileFunc: (chunk: Blob, index: number) => Promise<any>;
    finishFilePartUploadFunc: (md5: string) => Promise<any>;
  };
}

export class FileUploaderClient {
  fileUploaderClientOptions: IFileUploaderClientOptions;

  constructor(options: IFileUploaderClientOptions) {
    this.fileUploaderClientOptions = Object.assign(DEFAULT_OPTIONS, options);
  }

  /**
   * 将file对象进行分片,然后根据分片计算md5
   * @param file 要上传的文件
   * @returns 返回md5和分片列表
   */
  public async getChunkListAndFileMd5(
    file: File,
  ): Promise<{ md5: string; chunkList: Blob[] }> {
    return new Promise((resolve, reject) => {
      let currentChunk = 0;
      const chunkSize = this.fileUploaderClientOptions.chunkSize;
      const chunks = Math.ceil(file.size / chunkSize);
      const spark = new SparkMD5.ArrayBuffer();
      const fileReader = new FileReader();
      const blobSlice = getBlobSlice();
      const chunkList: Blob[] = [];

      fileReader.onload = function (e) {
        if (e?.target?.result instanceof ArrayBuffer) {
          spark.append(e.target.result);
        }
        currentChunk++;

        if (currentChunk < chunks) {
          loadNextChunk();
        } else {
          const computedHash = spark.end();
          resolve({ md5: computedHash, chunkList });
        }
      };

      fileReader.onerror = function (e) {
        console.warn('read file error', e);
        reject(e);
      };

      function loadNextChunk() {
        const start = currentChunk * chunkSize;
        const end =
          start + chunkSize >= file.size ? file.size : start + chunkSize;

        const chunk = blobSlice.call(file, start, end);
        chunkList.push(chunk);
        fileReader.readAsArrayBuffer(chunk);
      }

      loadNextChunk();
    });
  }

  /**
   * 上传文件方法,当FileUploaderClient的配置项中传入了requestOptions才能使用
   * 会依次执行getChunkListAndFileMd5、配置项中的initFilePartUploadFunc、配置项中的uploadPartFileFunc、配置项中的finishFilePartUploadFunc
   * 执行完成后返回上传结果,若有分片上传失败,则会自动重试
   * @param file 要上传的文件
   * @returns finishFilePartUploadFunc函数Promise resolve的值
   */
  public async uploadFile(file: File): Promise<any> {
    const requestOptions = this.fileUploaderClientOptions.requestOptions;
    const { md5, chunkList } = await this.getChunkListAndFileMd5(file);
    const retryList = [];

    if (
      requestOptions?.retryTimes === undefined ||
      !requestOptions?.initFilePartUploadFunc ||
      !requestOptions?.uploadPartFileFunc ||
      !requestOptions?.finishFilePartUploadFunc
    ) {
      throw Error(
        'invalid request options, need retryTimes, initFilePartUploadFunc, uploadPartFileFunc and finishFilePartUploadFunc',
      );
    }

    await requestOptions.initFilePartUploadFunc();

    for (let index = 0; index < chunkList.length; index++) {
      try {
        await requestOptions.uploadPartFileFunc(chunkList[index], index);
      } catch (e) {
        console.warn(`${index} part upload failed`);
        retryList.push(index);
      }
    }

    for (let retry = 0; retry < requestOptions.retryTimes; retry++) {
      if (retryList.length > 0) {
        console.log(`retry start, times: ${retry}`);
        for (let a = 0; a < retryList.length; a++) {
          const blobIndex = retryList[a];
          try {
            await requestOptions.uploadPartFileFunc(
              chunkList[blobIndex],
              blobIndex,
            );
            retryList.splice(a, 1);
          } catch (e) {
            console.warn(
              `${blobIndex} part retry upload failed, times: ${retry}`,
            );
          }
        }
      }
    }

    if (retryList.length === 0) {
      return await requestOptions.finishFilePartUploadFunc(md5);
    } else {
      throw Error(
        `upload failed, some chunks upload failed: ${JSON.stringify(
          retryList,
        )}`,
      );
    }
  }
}

Шесть, попробуй!

6-1. Серверная сторона

Давайте сначала напишем сервер с коа,easy-file-uploader-serverИспользование может быть сосредоточено на конфигурации маршрутизатора.

const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const router = require('./router')
const cors = require('@koa/cors')
const staticResource = require('koa-static')
const path = require('path')
const KoaRouter = require('koa-router')
const multer = require('@koa/multer')
const path = require('path')
const { FileUploaderServer } = require('easy-file-uploader-server')

const PORT = 10001

const app = new Koa()

const upload = multer()
const router = KoaRouter()

const fileUploader = new FileUploaderServer({
  tempFileLocation: path.join(__dirname, './public/tempUploadFile'),
  mergedFileLocation: path.join(__dirname, './public/mergedUploadFile'),
})

router.post('/api/initUpload', async (ctx, next) => {
  const { name } = ctx.request.body
  const uploadId = await fileUploader.initFilePartUpload(name)
  ctx.body = { uploadId }
  await next()
})

router.post('/api/uploadPart', upload.single('partFile'), async (ctx, next) => {
  const { buffer } = ctx.file
  const { uploadId, partIndex } = ctx.request.body
  const partFileMd5 = await fileUploader.uploadPartFile(uploadId, partIndex, buffer)
  ctx.body = { partFileMd5 }
  await next()
})

router.post('/api/finishUpload', async (ctx, next) => {
  const { uploadId, name, md5 } = ctx.request.body
  const { path: filePathOnServer } = await fileUploader.finishFilePartUpload(uploadId, name, md5)
  const suffix = filePathOnServer.split('/public/')[1]
  ctx.body = { path: suffix }
  await next()
})

app.use(cors())
app.use(bodyParser())
app.use(staticResource(path.join(__dirname, 'public')))
app.use(router.routes())
app.use(router.allowedMethods())
app.listen(PORT)
console.log(`app run in port: ${PORT}`)
console.log(`visit http://localhost:${PORT}/index.html to start demo`)

6-2. Клиентская сторона

Давайте напишем клиент с помощью React, главное — посмотреть на логику в компоненте APP.

import { useRef, useState } from 'react'
import './App.css'
import axios from 'axios'
import { FileUploaderClient } from 'easy-file-uploader-client'

const HOST = 'http://localhost:10001/'

function App() {
  const fileInput = useRef(null)
  const [url, setUrl] = useState<string>('')
  let uploadId = ''

  const fileUploaderClient = new FileUploaderClient({
    chunkSize: 2 * 1024 * 1024, // 2MB
    requestOptions: {
      retryTimes: 2,
      initFilePartUploadFunc: async () => {
        const fileName = (fileInput.current as any).files[0].name
        const { data } = await axios.post(`${HOST}api/initUpload`, {
          name: fileName,
        })
        uploadId = data.uploadId
        console.log('初始化上传完成')
        setUrl('')
      },
      uploadPartFileFunc: async (chunk: Blob, index: number) => {
        const formData = new FormData()
        formData.append('uploadId', uploadId)
        formData.append('partIndex', index.toString())
        formData.append('partFile', chunk)

        await axios.post(`${HOST}api/uploadPart`, formData, {
          headers: { 'Content-Type': 'multipart/form-data' },
        })
        console.log(`上传分片${index}完成`)
      },
      finishFilePartUploadFunc: async (md5: string) => {
        const fileName = (fileInput.current as any).files[0].name
        const { data } = await axios.post(`${HOST}api/finishUpload`, {
          name: fileName,
          uploadId,
          md5,
        })
        console.log(`上传完成,存储地址为:${HOST}${data.path}`)
        setUrl(`${HOST}${data.path}`)
      },
    },
  })

  const upload = () => {
    if (fileInput.current) {
      fileUploaderClient.uploadFile((fileInput.current as any).files[0])
    }
  }

  return (
    <div className="App">
      <h1>easy-file-uploader-demo</h1>
      <h3>选择文件后点击“上传文件”按钮即可</h3>
      <div className="App">
        <input type="file" name="file" ref={fileInput} />
        <input type="button" value="上传文件" onClick={upload} />
      </div>
      {url && <h3>{`文件地址:${url}`}</h3>}
    </div>
  )
}

export default App

6-3 Используйте эффект

Сначала выберите большой файл, а затем нажмите «Загрузить».После загрузки отображается адрес файла.在这里插入图片描述Посетите файл, похоже, что файл был успешно загружен на сервер, отлично!在这里插入图片描述

Более подробные примеры использования можно посмотреть на github:пример использования easy-file-uploader

автор:shadowings-zy