Node分片上傳和OSS上傳

风希落發表於2024-03-13

大檔案分片

切片就是為了解決大檔案上傳時間過長,最佳化體驗。將大檔案拆分成多個小檔案,依次上傳,上傳完畢後合併成原始檔。
瀏覽器的 Blob 提供了 slice 方法,可以擷取某個範圍的資料,而檔案上傳的 File 就是一種 Blob

image

前端可以透過 Blob.slice 進行檔案拆分,然後就是後端檔案合併。

image

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

image

前端分片時,每 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;
  }

此時再進行上傳

image

分片目錄名衝突

以檔名作為分片目錄,造成的結果就是會出現重複目錄,即兩個相同檔名的分片跑到一個目錄下,前端在傳入檔名時,可以加上隨機數(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);
});

image

後端分片合併

檔案分片上傳完畢後,可以再發請求讓這些檔案進行合併,比如傳入檔名,讓後端找這個檔案對應的分片目錄進行合併

  @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';
  }

image

需要注意的是,傳入的檔名必須是上傳時的檔名,而不是檔案的原本名

OSS 上傳(阿里雲)

本地儲存的檔案目錄結構

image

OSS 儲存的目錄結構,是由桶來儲存檔案的

image

購買 阿里雲 OSS 雲端儲存

image

建立 Bucket(桶)

image

上傳一個檔案,檢視儲存再 OSS 中的詳細資訊

image

此時在公網環境下就可以訪問該圖片

image

通常,生產環境下我們不會直接用 OSS 的 URL 訪問,而是會開啟 CDN,用網站域名訪問,最終回源到 OSS 服務

Node 整合 OSS

阿里雲提供了 OSS 的開發文件:Nodejs OSS物件儲存

image

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 所在區域

image

  • accessKeyIdaccessKeySecret,訪問憑證/私鑰

image

  • bucket 就是自己建立的Bucket名稱

引數都瞭解並完善之後執行程式碼,此時再去檢視 OSS 的檔案列表,就可以發現多了個檔案。

image

使用 RAM 子使用者 AccessKey

在點選進入 AccessKey 管理時,每次都會彈出以下內容,提示 AccessKey 不安全,讓使用子使用者 AccessKey

image

那就建立子使用者 AccessKey

image

建立完成之後,將程式碼中已有的憑證替換成新的

image

此時執行程式碼,會出現無許可權的報錯提示,需要開通許可權

image

開通許可權

image

此時再次執行程式碼就可以了。

image

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.");
});

執行得到的結果大概如圖所示

image

經過以上步驟,上傳 OSS 的地址 host,用的臨時 signaturepolicy 都有了,此時就能讓前端直接使用臨時簽名上傳。

前端使用臨時簽名

<!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 允許跨域請求

image

點選上傳即可

image

相關文章