大檔案分片
切片就是為了解決大檔案上傳時間過長,最佳化體驗。將大檔案拆分成多個小檔案,依次上傳,上傳完畢後合併成原始檔。
瀏覽器的 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 允許跨域請求
點選上傳即可