大文件分片

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

  • accessKeyIdaccessKeySecret,访问凭证/私钥

  • 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,用的临时 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 允许跨域请求

点击上传即可