Node分片上传和OSS上传

风希落 / 2024-03-14 / 原文

大文件分片

切片就是为了解决大文件上传时间过长,优化体验。将大文件拆分成多个小文件,依次上传,上传完毕后合并成源文件。
浏览器的 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