大文件分片
切片就是为了解决大文件上传时间过长,优化体验。将大文件拆分成多个小文件,依次上传,上传完毕后合并成源文件。
浏览器的 Blob 提供了 slice 方法,可以截取某个范围的数据,而文件上传的 File 就是一种 Blob
前端可以通过 Blob.slice
进行文件拆分,然后就是后端文件合并。
fs 的 createWriteStream
方法支持指定 start,也就是从什么位置开始写入。这样把每个分片按照不同位置写入文件里,就可以完成合并了。
编写常规上传接口
安装 multer 类型
pnpm i @types/multer -D
编写 controller 接收文件
@Post('upload')
@UseInterceptors(FilesInterceptor('files', 20, { dest: 'uploads' }))
uploadFile(@UploadedFiles() files: Express.Multer.File[]) {
return files;
}
安装静态资源访问包
pnpm i @nestjs/serve-static
设置可访问的静态资源
// app.module.ts
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client'),
}),
// ...
],
controllers: [AppController],
providers: [AppService],
})
编写请求联调
<body>
<input id="fileControll" type="file" multiple />
</body>
<script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
<script>
const fileInput = document.querySelector('#fileControll');
fileInput.addEventListener('change', async (event) => {
const files = event.target.files;
const data = new FormData();
// files 是一个伪数组,需转成数组
const f = Array.from(files).forEach((file) => data.append('files', file));
const res = await axios.post('/person/upload', data);
});
</script>
至此,一个最常规的文件上传前后端联调已经完成了。
分片上传
此时是需要做分片的,即前端文件拆分上传,后端文件合并,常规的文件上传是不适用的,需要对其进行改写。
前端文件分片上传
const fileInput = document.querySelector('#fileControll');
const chunkSize = 1000 * 1024; // 1024 就是 1k。*1000 就是每 1000k 拆分
fileInput.addEventListener('change', async (event) => {
const file = event.target.files[0]; // 暂时先上传一个文件进行测试
const chunks = [];
let startPos = 0;
while (startPos < file.size) {
chunks.push(file.slice(startPos, startPos + chunkSize));
startPos += chunkSize;
}
chunks.map((chunk, index) => {
const data = new FormData();
data.set('name', file.name + '-' + index);
data.append('files', chunk);
axios.post('/person/upload', data);
});
});
前端分片时,每 1000k 拆分成一份,最终可以看到文件在存储到后端时,拆分成了七份。
后端分片处理
创建分片目录
所有的分片都存储在 uploads 文件夹下,合并时是无法区分哪个分片是属于谁的,此时可以将每次上传的文件分文件夹存储,一个文件一个文件夹。
@Post('upload')
@UseInterceptors(FilesInterceptor('files', 20, { dest: 'uploads' }))
uploadFile(
@UploadedFiles() files: Express.Multer.File[],
@Body() body: { name: string },
) {
const fileName = body.name.match(/(.+)\-\d+$/)[1];
const chunkDir = 'uploads/chunks_' + fileName; // 以文件名为一个分片文件夹
if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir); // 文件夹不存在,创建
fs.cpSync(files[0].path, chunkDir + '/' + body.name); // 将传到 uploads 文件夹下的内容 copy 到 分片文件夹下
fs.rmSync(files[0].path); // 删除 uploads 文件夹下的文件
return files;
}
此时再进行上传
分片目录名冲突
以文件名作为分片目录,造成的结果就是会出现重复目录,即两个相同文件名的分片跑到一个目录下,前端在传入文件名时,可以加上随机数(uuid)等,这样可以避免该问题,当然,后端加也一样。
const randomStr = Math.random().toString().slice(2, 8);
chunks.map((chunk, index) => {
const data = new FormData();
data.set('name', randomStr + '_' + file.name + '-' + index);
// data.set('name', file.name + '-' + index);
data.append('files', chunk);
axios.post('/person/upload', data);
});
后端分片合并
文件分片上传完毕后,可以再发请求让这些文件进行合并,比如传入文件名,让后端找这个文件对应的分片目录进行合并
@Get('merge')
merge(@Query('name') name: string) {
const chunkDir = 'uploads/chunks_' + name; // 根据文件名读取分片目录中的文件
const files = fs.readdirSync(chunkDir);
let startPos = 0;
let count = 0;
files.map((file) => {
const filePath = chunkDir + '/' + file;
const stream = fs.createReadStream(filePath);
stream
.pipe(fs.createWriteStream('uploads/' + name, { start: startPos }))
.on('finish', () => {
// 合并完删除分片文件
count++;
if (count === files.length) {
fs.rm(chunkDir, { recursive: true }, () => {});
}
});
startPos += fs.statSync(filePath).size;
});
return 'merge file success';
}
需要注意的是,传入的文件名必须是上传时的文件名,而不是文件的原本名
OSS 上传(阿里云)
本地存储的文件目录结构
OSS 存储的目录结构,是由桶来存储文件的
购买 阿里云 OSS 云存储
创建 Bucket(桶)
上传一个文件,查看存储再 OSS 中的详细信息
此时在公网环境下就可以访问该图片
通常,生产环境下我们不会直接用 OSS 的 URL 访问,而是会开启 CDN,用网站域名访问,最终回源到 OSS 服务
Node 集成 OSS
阿里云提供了 OSS 的开发文档:Nodejs OSS对象存储
OSS 简单使用
先按照文档进行简单使用
mkdir oss-test
cd oss-test
npm init -y
npm i ali-oss # 安装sdk开发包
在 index.js 中,将官方示例粘贴过来研究
const OSS = require("ali-oss");
const path = require("path");
const client = new OSS({
// yourregion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: "yourregion",
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
// 填写Bucket名称。
bucket: "examplebucket",
});
async function put() {
try {
// 填写OSS文件完整路径和本地文件的完整路径。OSS文件完整路径中不能包含Bucket名称。
// 如果本地文件的完整路径中未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件。
const result = await client.put(
"login.png",
path.normalize("./api-login.2fcc9f35.jpg")
);
console.log(result);
} catch (e) {
console.log(e);
}
}
put();
文档代码中给出的解释,依次去寻找参数来源。
region
,bucket 所在区域
accessKeyId
和accessKeySecret
,访问凭证/私钥
bucket
就是自己创建的Bucket名称
参数都了解并完善之后执行代码,此时再去查看 OSS 的文件列表,就可以发现多了个文件。
使用 RAM 子用户 AccessKey
在点击进入 AccessKey 管理时,每次都会弹出以下内容,提示 AccessKey 不安全,让使用子用户 AccessKey
那就创建子用户 AccessKey
创建完成之后,将代码中已有的凭证替换成新的
此时执行代码,会出现无权限的报错提示,需要开通权限
开通权限
此时再次运行代码就可以了。
RAM 子用户的好处就是,就算 accessKey 泄露,由于有权限分配,可以直接解除该主体的 accessKey 访问权限
授权给第三方上传
授权第三方上传出现的原因是:
- 前端经过服务器,服务器再转存到 OSS,消耗服务器资源
- 前端直接传给 OSS,增加 accessKey 暴露风险
基于以上两点,给出的两全其美的解决方法就是授权给第三方上传,此处可查看 文档
Node 版获取临时签名完整代码,部分代码也可查看 文档
const express = require("express");
const moment = require("moment");
const { Buffer } = require("buffer");
const OSS = require("ali-oss");
const app = express();
const path = require("path");
const config = {
accessKeyId: "accessKeyId",
accessKeySecret: "accessKeySecret",
bucket: "bucket",
callbackUrl: "url", //
dir: "prefix/", // OSS文件的前缀
};
app.get("/", async (req, res) => {
const client = new OSS(config);
const date = new Date();
date.setDate(date.getDate() + 1);
const policy = {
expiration: date.toISOString(), // 请求有效期
conditions: [
["content-length-range", 0, 1048576000], // 设置上传文件的大小限制
// { bucket: client.options.bucket } // 限制可上传的bucket
],
};
// 跨域才设置
res.set({
"Access-Control-Allow-Origin": req.headers.origin || "*",
"Access-Control-Allow-Methods": "PUT,POST,GET",
});
//签名
const formData = await client.calculatePostSignature(policy);
//bucket域名
const host = `http://${config.bucket}.${
(await client.getBucketLocation()).location
}.aliyuncs.com`.toString();
//回调
const callback = {
callbackUrl: config.callbackUrl,
callbackBody:
"filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}",
callbackBodyType: "application/x-www-form-urlencoded",
};
//返回参数
const params = {
expire: moment().add(1, "days").unix().toString(),
policy: formData.policy,
signature: formData.Signature,
accessid: formData.OSSAccessKeyId,
host,
callback: Buffer.from(JSON.stringify(callback)).toString("base64"),
dir: config.dir,
};
res.json(params);
});
//接收回掉
app.post("/result", (req, res) => {
//公钥地址
const pubKeyAddr = Buffer.from(
req.headers["x-oss-pub-key-url"],
"base64"
).toString("ascii");
//判断
if (
!pubKeyAddr.startsWith("https://gosspublic.alicdn.com/") &&
!pubKeyAddr.startsWith("https://gosspublic.alicdn.com/")
) {
System.out.println("pub key addr must be oss addrss");
res.json({ Status: "verify not ok" });
}
res.json({ Status: "Ok" });
});
app.listen(9000, () => {
console.log("http://localhost:9000");
console.log("App of postObject started.");
});
运行得到的结果大概如图所示
经过以上步骤,上传 OSS 的地址 host
,用的临时 signature
和 policy
都有了,此时就能让前端直接使用临时签名上传。
前端使用临时签名
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<input type="file" id="fileControll" />
</body>
<script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
<script>
const fileInput = document.querySelector("#fileControll");
// 调用服务端的提供临时凭证接口
async function getOSSInfo() {
return {
expire: "1710427719",
policy: "eyJleHBpcmF0aW9uIjoiMjAyNC0wMy0xNFQxNDo0ODozOC42MDRaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF1dfQ==",
signature: "ncnYb+6AsWVquMzYJuDVJPOG3Y8=",
accessid: "LTAI5tMSeQWSbHF4Ky9QmDV4",
host: "http://jsonq.oss-cn-beijing.aliyuncs.com",
callback: "eyJjYWxsYmFja1VybCI6InVybCIsImNhbGxiYWNrQm9keSI6ImZpbGVuYW1lPSR7b2JqZWN0fSZzaXplPSR7c2l6ZX0mbWltZVR5cGU9JHttaW1lVHlwZX0maGVpZ2h0PSR7aW1hZ2VJbmZvLmhlaWdodH0md2lkdGg9JHtpbWFnZUluZm8ud2lkdGh9IiwiY2FsbGJhY2tCb2R5VHlwZSI6ImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCJ9",
dir: "prefix/",
};
}
fileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
const ossInfo = await getOSSInfo();
const formdata = new FormData();
formdata.append("key", file.name);
formdata.append("OSSAccessKeyId", ossInfo.accessid);
formdata.append("policy", ossInfo.policy);
formdata.append("signature", ossInfo.signature);
formdata.append("success_action_status", "200"); //让服务端返回200,不然,默认会返回204
formdata.append("file", file);
const res = await axios.post(ossInfo.host, formdata);
if (res.status === 200) {
const img = document.createElement("img");
img.src = ossInfo.host + "/" + file.name;
document.body.append(img);
alert("上传成功");
}
});
</script>
</html>
此时上传是有跨域限制的,有条件的情况下可以希望在项目的本地做proxy代理,此处直接让 OSS 允许跨域请求
点击上传即可