彻底搞懂大文件分片上传 Server 端实现(基于node)

388 阅读3分钟

学习了解

fs-extra

查看nodeJS文档:

const fse = require("fs-extra");

配置

定义存放分片的文件夹和存放合成之后的文件夹

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,
};

定义一个类FileUploaderServer表示我们的文件上传服务。

class FileUploaderServer {
    fileSpliter = '_SPLIT_';
    fileUploaderOptions;
    constructor(options) {
        this.fileUploaderOptions = Object.assign(DEFAULT_OPTIONS, options);
    }
    getOptions() {
        return this.fileUploaderOptions;
    }
}

定义fileSpliter作为分片的标志分割符号。
定义fileUploadOptions为我们的配置,以及函数getOptions()表示获取这些配置的方法。


初始化文件分片上传

初始化文件分片上传,实际上就是根据fileName和时间计算一个md5,并新建一个文件夹:

  • fileName 文件名
  • 返回上传的id

函数名:initFilePartUpload
判断文件夹是否存在,我们使用的是fse.ensureDir(文件夹路径);

这里,我们生成一个临时文件夹来保存图片的分片,这里我们使用fileName+日期的Hash来作为文件夹的名称。
这里我们使用nodeJS的crypto模块来生成 MD5 的Hash。

function calculateMd5(content) {
    const hash = crypto.createHash('md5');
    return hash.update(content).digest('hex');
}

接下来我们将生成的Hash串作为uploadId返回来。

const uploadId = calculateMd5(`${fileName}-${Date.now()}`);

接下来我们在tempFileLocation下创建一个名字为uploadId的文件夹。

1.首先检查这个文件夹是否存在,如果存在就不需要创建了,直接提示。
我们使用fse.existsSync(文件夹路径)来检查。

const uploadFolderPath = path.join(tempFileLocation, uploadId);
const uploadFolderExist = fse.existsSync(uploadFolderPath);
if (uploadFolderExist) {
    throw new exception_1.FolderExistException('found same upload folder, maybe you meet hash collision');
}

2.创建文件夹:

await fse.mkdir(uploadFolderPath);

最后我们返回我们这个文件夹的uploadId

return uploadId;

上传分片

上传分片,实际上是将partFile写入uploadId对应的文件夹中,写入的文件命名格式为partIndex${this.fileSpliter}md5

第一步:获取上传文件的文件夹位置;

const uploadFolderPath = await this.getUploadFolder(uploadId);

我们通过getUploadFolder(uploadId)来实现。
下面是具体函数内容:

显而易见,还是使用我们上边说的方法fse.ensureDir(文件夹位置)判断存放分片的文件夹是否存在,然后使用fse.existsSync(文件夹位置)来判断当前id的文件夹是否存在,其实也就是判断初始化接口是否调用成功。最后返回需要上传的文件夹的地址。

第二步:我们将上传的分片文件(partFile)的内容进行MD5处理,得到Hash串。(我们对内容进行编码方便我们过滤重复内容)。还是使用上边的方法calculateMd5

const partFileMd5 = util_1.calculateMd5(partFile);

第三步:我们生成当前文件的路径。使用partIndex表示当前分片的编号,fileSpliter我们上边讲了就是一个分隔符,最后是我们上边生成的内容的Hash。

const partFileLocation = path.join(uploadFolderPath, `${partIndex}${this.fileSpliter}${partFileMd5}.part`);

第四步:将文件(partFile)写入到我们上边得到的地址中。这里我们使用的是fse.writeFile()

await fse.writeFile(partFileLocation, partFile);

获取已经上传的分片信息

获取已上传的分片信息,实际上就是读取这个文件夹下面的内容

第一步:获取文件夹的位置getUploadFolder()

const uploadFolderPath = await this.getUploadFolder(uploadId);

第二步(核心):获取文件列表;
核心方法fse.readdir(dirPath)来获取。然后通过过滤来获取已上传的文件信息列表。

const dirList = await util_1.listDir(uploadFolderPath);

取消文件上传

取消文件上传,硬删除会直接删除文件夹,软删除会给文件夹改个名字

关键点,两个方法:

  1. fse.remove(文件夹路径);直接删除文件夹
  2. fse.rename(旧名称, 新名称);修改文件夹名称

合并分片

完成分片上传,实际上就是将所有分片都读到一起,然后进行md5检查,最后存到一个新的路径下。
第一步:获取合并之后的文件存放位置:

const { mergedFileLocation } = this.fileUploaderOptions;

第二步:确保文件夹的存在。

await fse.ensureDir(mergedFileLocation);

第三步:获取上传分片文件的位置。

const uploadFolderPath = await this.getUploadFolder(uploadId);

第四步:获取分片文件列表。

const dirList = await util_1.listDir(uploadFolderPath);

第五步:寻找.part结尾的文件。

const files = dirList.filter((item) => item.path.endsWith('.part'));

第六步:获取文件存放的路径

const mergedFileDirLocation = path.join(合并文件的位置(mergedFileLocation), 文件内容的md5这个是客户端传过来的);

第七步:确保文件夹的存在

await fse.ensureDir(mergedFileDirLocation);

第八步:生成存放文件的路径,这里我们使用客户端传入的fileName

const mergedFilePath = path.join(mergedFileDirLocation, fileName);

第九步:合并文件(核心)

  1. 获取所有文件的排序和文件(路径)。
files.map((item) => {
    const [index] = item.name.replace(/.part$/, '').split(fileSpliter);
    return {
        index: parseInt(index),
        path: item.path,
    };
});
  1. 对文件进行排序。
const sortedFileList = fileList.sort((a, b) => {
    return a.index - b.index;
});
  1. 合并文件
    这里使用第三方的插件multistream
const MultiStream = require("multistream");

创建读取数据流:

const readStreamList = sortedFileList.map((path) => {
    return fse.createReadStream(path);
});

将我们读取到的数据流传入到MultiStream实例中

const multiStream = new MultiStream(readStreamList);

创建写入的Stream:

const writeStream = fse.createWriteStream(输出路径);

multiStream调用pipe()方法

multiStream.pipe(writeStream);

最后就是监听:

const fd = fse.openSync(outputPath, 'w+');
multiStream.on('end', () => {
    fse.closeSync(fd);
    resolve(true);
});
multiStream.on('error', () => {
    fse.closeSync(fd);
    reject(false);
});

第十步:检查md5
服务端计算fileMd5

function calculateFileMd5(path) {
    return new Promise((resolve, reject) => {
        const hash = crypto.createHash('md5');
        const readStream = fse.createReadStream(path);
        readStream.on('error', (err) => {
            reject(err);
        });
        readStream.on('data', (data) => {
            hash.update(data);
        });
        readStream.on('end', function () {
            const md5 = hash.digest('hex');
            resolve(md5);
        });
    });
}

比较

if (mergedFileMd5 !== md5) {
    throw new exception_1.Md5Exception('md5 checked failed');
}

完整代码