前言
日常工作中,实现文件上传最常见的开发需求之一。一般来说,一个excel文件的大小也就在10MB以内,如果有好几十MB的文件,就勉强算是中等文件吧,只不过一次上传一个几十MB的文件,接口会慢一些。但是,如果碰到上百兆或者几个G以上的文件,例如视频、音频等,上述方式就不合适了。咱们就把 大文件拆分,进行分片上传,等上传完了,再将一片片文件合并一起,再恢复成原来的样子
即可。
1. 基于nestjs构建简单的前后端服务
本文的demo是基于nestjs搭建的,启动后台服务后,如果想要直接构建同源的前端服务,可以在nestjs框架的入口处增加如下配置:
js
async function bootstrap() {
......
// 增加之后,可以通过本地后台地址和端口启动前端服务
app.useStaticAssets('public', { prefix: '/static' });
.....
}
在 public
文件目录下面新建一个upload.html
文件,本地nestjs 服务启动之后,可以在浏览器输入地址:http://localhost:3000/static/upload.html 访问前端页面。这样一个简单的前后端服务搭建完成,我们基于此服务来实现大文件分片上传的demo。
2. 流程分析与问题思考
大文件的分片上传流程可以通过以下几个步骤实现:
-
- 在前端,将上传大文件拆分成一片又一片,并标记分片顺序和文件hash;
-
- 前端发起上传请求,每一次请求给后端带一片文件,在后端接受分片的所有文件并缓存;
-
- 当所有分片文件都上传完,再通过前端发出合并请求,告知后端将分片的文件合并;
基于上述的流程,我们需要思考以下问题:如果某个文件已经存在(曾经上传过),那我还需要上传吗?如果同一时刻,不同的用户都在上传,我们怎么鉴别那些文件是属于同一个文件类的呢?是否很容易造成分片混淆?因此我们需要具体的文件id,就不会操作错了。前端如何确定文件的id,如何才能得到文件的唯一标识?
此处我们需要依赖一个插件 spark-md5
3. 通过 spark-md5 标记分片文件的 hash
spark-md5
是基于md5的一种优秀的算法,可以通过文件读取后,用来计算文件的唯一身份证标识 ------ hash值。 先全局引入该插件:
js
<script src='./spark-md5.js'></script>
实现通过SparkMD5来获取文件hash的逻辑:
js
const getFileHash = (file) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = function (e) {
let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve(fileMd5);
}
});
}
如果直接计算一个整文件的hash值,文件小还好,如果文件比较大的,直接计算一整个文件的hash值,就会比较慢了。大文件分片不仅仅可以用于发送请求传递给后端,也可以用于计算大文件的hash值。直接计算一个大文件hash值任务慢,那就拆分成一些小任务,这样效率也提升了不少
4. 实现大文件的分片
文件file 是一种特殊的blob文件,因此可以通过 slice
方法进行切割,这是实现大文件分片的主要方法。
js
const chunkSize = 1024 * 1024 * 1; // 预设切片大小限制 1MB
const createChunks = (file) => {
// 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
const chunks = [];
// 文件大小.slice(开始位置,结束位置)
let start = 0;
let index = 0;
while (start < file.size) {
let curChunk = file.slice(start, start + chunkSize);
chunks.push({
file: curChunk,
uploaded: false,
chunkIndex: index,
fileHash: fileHash,
});
index++;
start += chunkSize;
}
return chunks;
}
5. 前后端实现分片上传接口
前端通过表单提交的形式发送数据到服务端,表单中包含切割分片后的blob文件、文件名称、分片的文件hash、分片的文件序号等信息:
js
function uploadHandler(chunk, chunks){
return new Promise(async (resolve, reject) => {
try {
let fd = new FormData();
fd.append('fileName', fileName);
fd.append('file', chunk.file);
fd.append('total', chunks.length);
fd.append('fileHash', chunk.fileHash);
fd.append('chunkIndex', chunk.chunkIndex);
let result = await fetch('/upload/fileUpload', {
method: 'POST',
body: fd
}).then(res => res.json());
chunk.uploaded = true;
resolve(result)
} catch (err) {
reject(err)
}
})
}
为了实现后端接口, 通过 nest g resource upload
实现一个upload模块,为了处理理文件上传,Nest 提供了一个基于 Express
的 multer
中间件,MulterModule.register
设置文件最终默认的存放路径,在UploadModule
中实现以下逻辑:
js
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { join, extname } from 'path';
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: join(__dirname, '../files'),
filename: (_, file, callback) => {
const fileName = `${
new Date().getTime() + extname(file.originalname)
}`;
return callback(null, fileName);
},
}),
}),
],
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule {}
然后在UploadController
中新增接口/fileUpload
,获取默认的文件缓存路径 file.path
并将分片的文件移动到新的文件目录下统一处理:
js
// 接受前端分片的大文件上传
@Post('fileUpload')
@UseInterceptors(FileInterceptor('file'))
fileUpload(@UploadedFile() file, @Req() req) {
const { fileName, fileHash, chunkIndex } = req.body || {};
if (!existsSync(newPath)) {
mkdirSync(newPath);
}
rename(file.path, `${newPath}/${fileHash}-${chunkIndex}`, (err) => {
if (err) {
console.log('err', err);
}
console.log('上传成功');
});
}
6. 前后端实现分片合并的接口
在分片请求的接口结束之后,发起合并的接口,请求参数包括文件hash、文件名等信息。
js
// 发起合并分片
function merge(fileName,fileHash,total){
let result = fetch('/upload/fileMerge', {
method: 'POST',
headers:{
'Content-Type':'application/json'
},
body: JSON.stringify({
fileName: fileName,
fileHash: fileHash,
total: total
})
}).then(res => res.json());
}
后台在UploadController
中新增接口/fileMerge
,通过合并所有分片文件实现文件的统一。
js
@Post('fileMerge')
fileMerge(@Req() req) {
const { fileHash, fileName, total } = req.body;
const mergePathArr = [];
// 找出所有切片文件
for (let i = 0; i < total; i++) {
mergePathArr.push(`${newPath}/${fileHash}-${i}`);
}
// 合并切片文件
mergePathArr.forEach((path) => {
const content = readFileSync(path);
appendFileSync(`${newPath}/${fileName}`, content);
});
}
7. 完整代码
前端:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件上传</title>
<script src='./spark-md5.js'></script>
</head>
<body>
<h3>大文件上传</h3>
<input type="file">
</body>
<script>
// 使用单独常量保存预设切片大小 1MB
const chunkSize = 1024 * 1024 * 1;
// 分片
const createChunks = (file) => {
// 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
const chunks = [];
// 文件大小.slice(开始位置,结束位置)
let start = 0;
let index = 0;
while (start < file.size) {
let curChunk = file.slice(start, start + chunkSize);
chunks.push({
file: curChunk,
uploaded: false,
chunkIndex: index,
fileHash: fileHash,
});
index++;
start += chunkSize;
}
return chunks;
}
// 通过SparkMD5来获取文件hash
const getFileHash = (file) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = function (e) {
let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve(fileMd5);
}
});
}
//发起合并分片
function merge(fileName,fileHash,total){
let result = fetch('/upload/fileMerge', {
method: 'POST',
headers:{
'Content-Type':'application/json'
},
body: JSON.stringify({
fileName: fileName,
fileHash: fileHash,
total: total
})
}).then(res => res.json());
}
//分片上传
function uploadHandler(chunk, chunks){
return new Promise(async (resolve, reject) => {
try {
let fd = new FormData();
fd.append('fileName', fileName);
fd.append('file', chunk.file);
fd.append('total', chunks.length);
fd.append('fileHash', chunk.fileHash);
fd.append('chunkIndex', chunk.chunkIndex);
let result = await fetch('/upload/fileUpload', {
method: 'POST',
body: fd
}).then(res => res.json());
chunk.uploaded = true;
resolve(result)
} catch (err) {
reject(err)
}
})
}
// 文件相关信息
let [ fileHash, fileName, file ] = [ '', '', document.querySelector('input') ]
//提交文件
file.onchange = async (e)=>{
let file = e.target.files[0];
// 设置文件名
fileName = file.name;
// 获取文件hash值
fileHash = await getFileHash(file);
// 获取分片之后的文件数据
chunks = createChunks(file);
// 分段上传分片文件
chunks.forEach((chunk)=>{
uploadHandler(chunk,chunks);
});
//模拟分片完成之后,发起合并分片文件请求
setTimeout(()=>{
merge(fileName,fileHash,chunks.length);
},1000)
}
</script>
</html>
后端:
ts
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
Res,
Body,
Req,
} from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { join, resolve } from 'path';
import {
rename,
existsSync,
mkdirSync,
appendFileSync,
readFileSync,
} from 'fs';
const newPath = resolve(__dirname, `../../../files`);
@Controller('upload')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
// 接受前端分片的大文件上传
@Post('fileUpload')
@UseInterceptors(FileInterceptor('file'))
fileUpload(@UploadedFile() file, @Req() req) {
const { fileName, fileHash, chunkIndex } = req.body || {};
if (!existsSync(newPath)) {
mkdirSync(newPath);
}
rename(file.path, `${newPath}/${fileHash}-${chunkIndex}`, (err) => {
if (err) {
console.log('err', err);
}
console.log('上传成功');
});
}
// 合并切片
@Post('fileMerge')
fileMerge(@Req() req) {
const { fileHash, fileName, total } = req.body;
const mergePathArr = [];
// 找出所有切片文件
for (let i = 0; i < total; i++) {
mergePathArr.push(`${newPath}/${fileHash}-${i}`);
}
// 合并切片文件
mergePathArr.forEach((path) => {
const content = readFileSync(path);
appendFileSync(`${newPath}/${fileName}`, content);
});
}
}
总结
- 前端大文件上传核心是
利用 Blob.prototype.slice
方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
。 - 借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了
并发
传多个小的文件切片,可以大大减少上传时间。 - 由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序
- 服务端负责接受前端传输的切片,并在接收到所有切片后
合并
。此处简单的做法就是前端可以额外发一个请求,主动通知服务端进行切片的合并 - 当然还存在
断点续传
的场景需要讨论,我们后面会进一步拓展,此处只是一个简单的功能实现。