大文件分片上传实战与优化(后端Part)
1. 需求分析
1.1 前端部分
详见: 超详细的大文件分片上传⏫实战与优化(前端部分) juejin.cn/post/735310…
1.2 后端接口设计
- 检查文件是否已经上传过
- 查询待上传的文件分片
- 接收文件分片
- 校验分片(此处校验逻辑同前端)
- 合并分片
后端这里没什么亮点都基操 ...
1.3 技术选型
前端: Angular + NG-Zorro
后端: Nest.js + Prisma + MySQL + Minio(文件存储)
(OOP 的大胜利)
1.4 表结构
主要字段如下
bucket_name: 桶名字
filename: 文件名
hash: 文件 Hash
size: 文件大小
meta_data: 文件元数据
2. 检查文件是否已经上传过
2.1 思路
根据文件的 Hash 或默克尔树的树根 Hash 值和文件的元数据来确定文件是否已经上传过了, 将数据存在数据库中
如果已经存在了就直接返回文件的 URL
其中文件元数据包括: 文件大小, 上次修改时间, 文件类型
2.2 实现
此处可以再加上元数据的传参(过于简单, 我就没加)
Controller
@Get('exist')
async checkIfExist(@Query('hash') hash: string, @Query('size') size: string) {
const fileUrl = await this.fileInfoSvc.checkIfExist(hash, size);
return R.ok(fileUrl);
}
Service
async checkIfExist(hash: string, size: string) {
const foundRecord = await this.prismaSvc.bizFileInfo.findFirst({
where: {
hash,
size,
},
select: {
bucketName: true,
filename: true,
},
});
if (foundRecord) {
return foundRecord.bucketName + '/' + foundRecord.filename;
}
return '';
}
3. 查询待上传的文件分片
3.1 思路
将文件的 hash 值作为存放文件分片的目录名, 然后比对这个目录下的文件名
其中文件分片的名字就是分片的 hash 值 + 分片的序号(合并分片时使用)
3.2 实现
此处先将后续要用到的 fs/promise 包下的部分 API 做了封装
这里要注意的是, 尽量不要使用带 XXXSync 名字的 fs 中的 API, 这会导致整个应用阻塞, 直到 XXXSync 的操作完成!
// fs-util.ts
import * as fsp from 'fs/promises';
import * as fsc from 'fs';
/**
* 检查文件夹是否存在
* @param dirPath 文件夹的路径
*/
async function checkDirExists(dirPath: string) {
try {
await fsp.access(dirPath);
return true; // 文件夹存在
} catch {
return false; // 文件夹不存在或无法访问
}
}
/**
* 创建目录, 如果父级目录不存在则会被自动创建
* @param dirPath
*/
async function createDir(dirPath: string) {
return await fsp.mkdir(dirPath, { recursive: true });
}
/**
* 移动文件 (先复制后删除)
* @param source 源路径
* @param destination 目标路径
* @param destinationParent 目标路径的父路径, 用于检查文件是否移动成功
*/
async function moveFile(
source: string,
destination: string,
destinationParent: string,
) {
return new Promise<void>((rs, rj) => {
try {
const readStream = fsc.createReadStream(source);
const writeStream = fsc.createWriteStream(destination);
readStream.pipe(writeStream);
readStream.on('end', async () => {
await fsp.readdir(destinationParent);
await fsp.unlink(source);
rs();
});
} catch (e) {
rj(e);
}
});
}
/**
* 读取一个文件并将它转成 Buffer
* @param path
*/
async function readFileAsBuffer(path: string) {
const readStream = fsc.createReadStream(path);
const chunks = [];
return new Promise<Buffer>((rs, rj) => {
readStream.on('data', (chunk) => {
chunks.push(chunk); // 收集数据块
});
readStream.on('end', () => {
const buffer = Buffer.concat(chunks); // 合并所有数据块构成Buffer
rs(buffer);
});
readStream.on('error', (e) => {
rj(e);
});
});
}
export { checkDirExists, createDir, moveFile, readFileAsBuffer };
Controller
export class MinioController {
separator = '__';
chunkDir = 'uploads' + path.sep + 'chunks' + this.separator;
bucketName = 'my-file';
@Post('chunks')
async getExistChunks(
@Body('hash') hash: string,
@Body('hashList') hashList: string[],
) {
const chunkDir = this.chunkDir + hash;
if (await checkDirExists(chunkDir)) {
const existHashList = await fsp.readdir(chunkDir);
const hashSet = new Set<string>(
existHashList.map((file) => file.split(this.separator)[1]),
);
return R.ok(hashList.filter((_hash: string) => !hashSet.has(_hash)));
} else {
return R.ok(hashList);
}
}
}
4. 接收文件分片
4.1 思路
使用集成好的 multer 的包来接收分片
4.2 实现
Controller
其中 @UseInterceptors() 用来先接收前端传来的分片, 其中 dest 是临时存储分片的地方
当分片完整接收后, 会将它移动到之前以文件 Hash 命名的目录中
注意: 此处上传不完整的分片不会被移动到目标目录中
@Post('upload')
@UseInterceptors(
FilesInterceptor('files', 100, { dest: `uploads${path.sep}temp` }),
)
async uploadFiles(
@UploadedFiles() files: Array<Express.Multer.File>,
@Body()
body: {
name: string; // 文件名
index: string; // chunk index
fileHash: string; // 文件 Hash
chunkHash: string; // chunk Hash
},
) {
const chunkDir = `uploads${path.sep}chunks${this.separator}${body.fileHash}`;
if (!(await checkDirExists(chunkDir))) await createDir(chunkDir);
// 移动缓存文件到目录中, 并改文件名
const source = files[0].path;
const destination =
chunkDir +
path.sep +
body.name +
this.separator +
body.chunkHash +
this.separator +
body.index;
await moveFile(source, destination, chunkDir);
return R.ok();
}
5. 校验分片
5.1 思路
校验分片时的 hash 策略需要与前端一致
当本地分片的 hash 值与前端传来的 hash 值不同时删除文件分片
此处在 Node 下计算 MD5 速度足够快, 暂不需要使用线程池 / Worker 池, 也可以考虑使用 pm2 多开 node 服务
5.2 实现
// Utils.ts
import * as crypto from 'crypto';
import { buf } from 'crc-32';
import { readFileAsBuffer } from './fs-util';
async function md5ForFile(path: string) {
const buffer = await readFileAsBuffer(path);
const hash = crypto.createHash('md5');
hash.update(buffer);
return hash.digest('hex');
}
async function crc32ForFile(path: string, seed = 0) {
const getCrcHex = (crc: number) => (crc >>> 0).toString(16);
const buffer = await readFileAsBuffer(path);
const crc = buf(buffer, seed);
return getCrcHex(crc);
}
export { md5ForFile, crc32ForFile };
Controller
@Post('verify2')
async verifyChunks2(
@Body('hash') hash: string,
@Body('hashList') hashList: string[],
) {
// 分片数量小于 borderCount 用 MD5, 否则用 CRC32 算 Hash
const BORDER_COUNT = 100;
const chunkDir = this.chunkDir + hash;
if (await checkDirExists(chunkDir)) {
let localChunkHashList: string[] = [];
const chunkNames = await this.readChunksDirWithSorted(chunkDir);
if (hashList.length <= BORDER_COUNT) {
const getMd5ForFile = chunkNames.map((fileName) =>
md5ForFile(chunkDir + path.sep + fileName),
);
localChunkHashList = await Promise.all(getMd5ForFile);
} else {
// 直接算分片的 crc 32
const getCrc32ForFile = chunkNames.map((fileName) =>
crc32ForFile(chunkDir + path.sep + fileName),
);
localChunkHashList = await Promise.all(getCrc32ForFile);
}
// 删除损坏的 chunk
const existHashSet = new Set<string>(localChunkHashList);
const brokenChunkList = hashList.filter(
(_hash: string) => !existHashSet.has(_hash),
);
const brokenChunkSet = new Set<string>(brokenChunkList);
const deleteBrokenChunks = chunkNames
.filter((chunkName) =>
brokenChunkSet.has(chunkName.split(this.separator).at(-2)),
)
.map((chunkName) => fsp.unlink(chunkDir + path.sep + chunkName));
await Promise.all(deleteBrokenChunks);
return R.ok(brokenChunkList);
} else {
return R.error(SysCodeEnum.NO_SUCH_FILE);
}
}
6. 合并分片
6.1 思路
!!! 在 Node 下使用 fs 包合并文件概率导致合并后文件损坏, 原因未知 !!!
在 Minio 中提供了合并分片的 API, 所以直接在 Minio 中做合并操作, 而不是在 Node 中
6.2 实现
Controller
@Post('merge2')
async mergeInMinio(
@Body('hash') hash: string,
@Body('name') name: string,
@Body('size') size: string,
@Body('metadata') metadata: string,
) {
const chunkDir = this.chunkDir + hash;
const chunkNames = await this.readChunksDirWithSorted(chunkDir);
for (const chunkName of chunkNames) {
await this.minioSvc.uploadFilePromisify(
chunkDir + path.sep + chunkName,
chunkName,
this.bucketName,
);
}
// 使用 uuid 多用户同时 Merge 一个文件时会 Merge 出两个不同文件
// const fileName = generateUUID() + this.separator + name;
// 改为使用 hash 加文件名
const fileName = hash + this.separator + name;
await this.minioSvc.mergeFile(chunkNames, fileName, this.bucketName);
await this.minioSvc.removeFile(this.bucketName, chunkNames);
await this.fileInfoSvc.recordHash(
hash,
this.bucketName,
fileName,
size,
metadata,
);
await fsp.rm(chunkDir, { recursive: true, force: true });
return R.ok(`/${this.bucketName}/${fileName}`);
}
// minioSvc.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';
@Injectable()
export class MinioService {
minio: typeof Minio;
minioClient: Minio.Client;
constructor(private configSvc: ConfigService) {
this.minio = Minio;
this.minioClient = new Minio.Client({
endPoint: this.configSvc.get<string>('MINIO_HOST'),
port: parseInt(this.configSvc.get<string>('MINIO_PORT')),
useSSL: false,
accessKey: this.configSvc.get<string>('MINIO_ACCESS_KEY'),
secretKey: this.configSvc.get<string>('MINIO_SECRET_KEY'),
});
}
async uploadFilePromisify(
path: string,
fileName: string,
bucketName: string,
metaData: any = {},
) {
await this.minioClient.fPutObject(bucketName, fileName, path, metaData);
return `/${bucketName}/${fileName}`;
}
mergeFile(chunksName: string[], fileName: string, bucketName: string) {
const sourceList = chunksName.map(
(name) =>
new this.minio.CopySourceOptions({
Bucket: bucketName,
Object: name,
}),
);
const destOption = new this.minio.CopyDestinationOptions({
Bucket: bucketName,
Object: fileName,
});
return this.minioClient.composeObject(destOption, sourceList);
}
async removeFile(bucketName: string, fileNameList: string[]) {
await this.minioClient.removeObjects(bucketName, fileNameList);
}
}
// fileInfoSvc.ts
/**
* 记录 merge 好的文件
* @param hash 文件 Hash
* @param bucketName
* @param filename 文件名
* @param size 文件大小, 单位 KB
* @param metadata 元数据, Json 字符串
*/
async recordHash(
hash: string,
bucketName: string,
filename: string,
size: string, // 单位 KB
metadata: string,
) {
await this.prismaSvc.bizFileInfo.create({
data: {
id: generateUUID(),
bucketName,
filename,
hash,
size,
metaData: metadata,
uploadDate: new Date(),
},
});
}
7. 总结
完整 Demo 仓库:
前端项目: github.com/Tkunl/kun-u…
后端项目: github.com/Tkunl/kun-u…
此外本文可能有少许不准确或者有误的地方,欢迎评论区赐教。最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发 ❤❤❤
文章参考: 一文吃透👉大文件分片上传、断点续传、秒传⏫ juejin.cn/post/732414…
转载请注明原作者, 和原文链接, 谢谢 ! ! !