文件上传

文件上传的原理

计算机中所有的内容:文字、数字、图片、音频等最终都会使用二进制来表示。

针对服务器来说,文件上传就是读取客户端传递过来的文件流,生成新的文件并保存在磁盘中。

而前端上传文件就是通过 <input type="file" />来进行文件选择,监听 change 事件来获取 File 对象。File 对象可以分为两部分:可见的元数据和不可见的文件流
其中不可见是相对的,可以使用 FileReader 来读取文件流,变成可见的。

ts 复制代码
function readFileAsBinary(file) {
  const reader = new FileReader();
  reader.onload = function (e) {
    const arrayBuffer = e.target.result;
    const uint8Array = new Uint8Array(arrayBuffer);
    let hexString = "";
    for (let i = 0; i < uint8Array.length; i++) {
      hexString += uint8Array[i].toString(16).padStart(2, "0"); // padStart确保每个字节都是两位
      if (i !== uint8Array.length - 1) {
        hexString += " "; // 每个字节之间添加空格,便于阅读
      }
    }
    console.log("不可见的文件流:", hexString);
  };
  reader.readAsArrayBuffer(file);
}

前端文件上传都是通过 formData 对象的形式,其默认类型是 multipart/form-data 发送数据。其格式大致是这样的:

boundary 作为分割线。

当服务器接收到了客户端传递过来的 formData 对象,就会读取其中的信息。

ts 复制代码
const http = require("http");
const fs = require("fs");
const server = http.createServer((req, res) => {
  // 创建一个可写流,用来保存上传文件的内容
  const writeStram = fs.createWriteStream("./new.png", {
    flags: "a+",
  });
  // 监听data,写入
  req.on("data", (data) => {
    writeStram.write(data);
  });
  // 监听写入完成
  req.on("end", () => {
    writeStram.close();
    res.end("文件上传成功");
  });
});
server.listen(8000, () => {
  console.log("The Server is running ~~");
});

会发现,写入文件成功,但是文件打不开,为什么呢?

在流的写入过程中,试着打印一下文件流内容(默认为 Buffer,要设置转码 binary,就会转化为 ASCII 编码的文本),发现文件流会多出一些东西:

绿色部分代表多余的东西

  • 分隔符:boundary
  • FormData 对象的其他 key-value 信息
  • 文件的基本信息

红色部分代表所需的文件流。

当文件流混入不需要的东西,文件自然就打不开了。那么就需要把这些多余的内容删除掉,才能使文件打开。

如何处理呢?简单的来说,就是把无用的信息截取掉。

ts 复制代码
const http = require("http");
const fs = require("fs");
const server = http.createServer((req, res) => {
  req.setEncoding("binary");

  // 拿取分隔符 boundary
  const boundary = req.headers["content-type"]
    .split("; ")[1]
    .replace("boundary=", "");

  let formData = "";
  req.on("data", (data) => {
    formData += data;
  });

  req.on("end", () => {
    // 1.截图从image/jpeg位置开始后面所有的数据
    const imgType = "image/jpeg";
    const imageTypePosition = formData.indexOf(imgType) + imgType.length;
    let imageData = formData.substring(imageTypePosition);
    // 2.imageData开始位置会有两个空格
    imageData = imageData.replace(/^\s\s*/, "");
    // 3.替换最后的boundary
    imageData = imageData.substring(0, imageData.indexOf(`--${boundary}--`));
    fs.writeFile("./bar.png", imageData, "binary", () => {
      res.end("文件上传成功~");
    });
  });
});

上面只是简单的示例,还是有很多细节要处理的,以及兼容多种情况。这时候就可以使用 multer 第三方库,其内部帮我们解决了上面的所有问题。

看看 multer 是怎么使用的呢?

multer 基本语法

multer 包是用来处理 multipart/form-data 格式的文件上传请求的。

ts 复制代码
const multer = require("multer");
const upload = multer({ dest: "uploads/" });

调用 multer 返回的一个对象,该对象的 single 方法处理单个字段的单个文件,array 方法处理单个字段的多个文件,fields 方法处理多个字段的文件,any 处理任意数量字段的文件,分别用 req.file 和 req.files 来取解析出的文件信息。

针对其余非文件字段不会处理,还是通过 req.body 来取。

类似文件数量过多等错误,会抛出对应的 error 对象,在错误处理中间件里处理并返回对应的响应就好了。

下面以 express 为例

ts 复制代码
// 单文件上传 single 方法,指定 key 值为 aaa
app.post("/aaa", upload.single("aaa"), function (req, res, next) {
  console.log("req.file", req.file);
  console.log("req.body", req.body);
});

// 多文件上传 array 方法,指定 key 值为 aaa,最多上传2个文件
app.post("/aaa", upload.array("aaa", 2), function (req, res, next) {
  console.log("req.files", req.files);
  console.log("req.body", req.body);
});

当文件上传多了就会报错,在中间件的第四个参数,进行错误处理。

ts 复制代码
app.post(
  "/bbb",
  upload.array("bbb", 2),
  function (req, res, next) {
    console.log("req.files", req.files);
    console.log("req.body", req.body);
  },
  function (err, req, res, next) {
    if (err instanceof MulterError && err.code === "LIMIT_UNEXPECTED_FILE") {
      res.status(400).end("Too many files uploaded");
    }
  }
);

如果文件上传分为多个 key 值,就需要单独设置(每个 key 值也支持多文件上传)

ts 复制代码
app.post(
  "/ccc",
  upload.fields([
    { name: "aaa", maxCount: 3 }, // aaa 最多上传3个文件
    { name: "bbb", maxCount: 2 }, // bbb 最多上传2个文件
  ]),
  function (req, res, next) {
    console.log("req.files", req.files);
    console.log("req.body", req.body);
  }
);

// aaa: [{}, {}]  bbb: [{}, {}]

上传文件时不知道 key,使用 any 方法

ts 复制代码
app.post("/ddd", upload.any(), function (req, res, next) {
  console.log("req.files", req.files);
  console.log("req.body", req.body);
});
// [ {fieldName: 'xxx', ...fileInfo} ]

设置上传的路径,及设置独特的文件名

ts 复制代码
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    try {
      fs.mkdirSync(path.join(process.cwd(), "my-uploads"));
    } catch (e) {}
    cb(null, path.join(process.cwd(), "my-uploads"));
  },
  filename: function (req, file, cb) {
    const uniqueSuffix =
      Date.now() +
      "-" +
      Math.round(Math.random() * 1e9) +
      "-" +
      file.originalname;
    cb(null, file.fieldname + "-" + uniqueSuffix);
  },
});

NestJS 文件上传

创建一个 Nest 项目,然后再创建一个 upload 模块,专门用于文件上传。

创建

bash 复制代码
nest g mo upload --no-spec
nest g co upload --no-spec

安装

bash 复制代码
pnpm add multer
pnpm add @types/multer -D

看看 upload.controller.ts 初始代码

ts 复制代码
@Controller("upload")
export class UploadController {}

接下来就是实现文件上传功能:

单文件上传

ts 复制代码
import { UseInterceptors, UploadedFile } from "@nestjs/common";
import { Express } from "express";
import { FileInterceptor } from "@nestjs/platform-express";
export class UploadController {
  @Post("single")
  @UseInterceptors(
    FileInterceptor("file", {
      dest: "uploads",
    })
  )
  handleSingleFile(@UploadedFile() file: Express.Multer.File, @Body() body) {
    return {
      data: file,
      message: "上传成功",
    };
  }
}
  • 单文件上传使用拦截器 FileInterceptor(内部实现原理是 multer.single()), 指定 key 和上传的目录。
  • 单文件使用 @UploadedFile() 获取上传的文件信息,就是一个 file 对象信息

多文件上传 1

针对设置单个 key 值和已知数量

ts 复制代码
import { UseInterceptors, UploadedFiles } from "@nestjs/common";
import { Express } from "express";
import { FilesInterceptor } from "@nestjs/platform-express";
export class UploadController {
  @Post("multi")
  @UseInterceptors(
    FilesInterceptor("files", 2, {
      dest: "uploads",
    })
  )
  handleMultiFile(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body
  ) {
    console.log(files, body);
    return {
      data: files,
      message: "上传成功",
    };
  }
}
  • 多文件上传使用拦截器 FilesInterceptor(内部实现原理是 multer.array()), 指定 key 和最大上传数量,以及上传的目录。
  • 多文件使用 @UploadedFiles() 获取上传的文件信息,拿取到的数 file 对象数组。

多文件上传 2

针对设置多个 key 值和已知数量

ts 复制代码
export class UploadController {
  @Post("multi2")
  @UseInterceptors(
    FileFieldsInterceptor(
      [
        { name: "files1", maxCount: 2 },
        { name: "files2", maxCount: 3 },
      ],
      { dest: "uploads" }
    )
  )
  handleMulti2File(
    @UploadedFiles()
    files: {
      file1: Array<Express.Multer.File>;
      file2: Array<Express.Multer.File>;
    },
    @Body() body
  ) {
    console.log(files, body);
    return {
      message: "上传成功",
      data: files,
    };
  }
}
  • 返回的结果是键值对组合 ,其 value 是 file 对象数组。

多文件上传 3

针对不知道 key 值的情况

ts 复制代码
export class UploadController {
  @Post("multi3")
  @UseInterceptors(
    AnyFilesInterceptor({
      dest: "uploads",
    })
  )
  handleAnyFile(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body
  ) {
    console.log("files", files);
    return {
      message: "上传成功",
      data: files,
    };
  }
}
  • 使用 AnyFilesInterceptor 来接受多个文件(key 值是任意的),返回的是 file 对象数组,里面存在 filedname 就是不同的 key 值。

校验文件类型及大小

tsx 复制代码
export class UploadController {
  @Post("multi4")
  @UseInterceptors(
    AnyFilesInterceptor({
      dest: "uploads",
    })
  )
  handleAnyFile2(
    @UploadedFiles(
      // 文件校验
      new ParseFilePipe({
        // 异常抛出
        exceptionFactory: (err) => {
          throw new HttpException("文件上传失败:" + err, HttpStatus.NOT_FOUND);
        },
        // 校验
        validators: [
          // 大小校验
          new MaxFileSizeValidator({ maxSize: 2048 }),
          // 类型校验
          new FileTypeValidator({ fileType: "image/jpeg" }),
        ],
      })
    )
    files: Array<Express.Multer.File>,
    @Body() body
  ) {
    console.log(body);
    return {
      message: "上传成功",
      data: files,
    };
  }
}
  • UploadedFiles 装饰器中使用 ParseFilePipe 来进行参数验证,里面接受异常处理和校验的配置。
  • nest 中提供了校验器: MaxFileSizeValidatorFileTypeValidator。常用的也是这两个。
  • 当发送不合适的文件,就会抛出异常。

配置上传目录及文件命名

定义 storage 对象

ts 复制代码
/**
 * @file: multer 配置目录及文件名的生成规则
 */
import * as multer from "multer";
import * as fs from "fs";
import * as path from "path";

export const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    try {
      fs.mkdirSync(path.join(process.cwd(), "my-uploads"));
    } catch (e) {}

    cb(null, path.join(process.cwd(), "my-uploads"));
  },
  filename: function (req, file, cb) {
    const uniqueSuffix =
      Date.now() +
      "-" +
      Math.round(Math.random() * 1e9) +
      "-" +
      file.originalname;
    cb(null, uniqueSuffix);
  },
});

AnyFilesInterceptor 使用配置对象 storage

文件重命名成功,放在指定的目录下。

NestJS 大文件上传

针对大文件上传,就需要分片上传,加快上传速度(并行上传),提高用户体验。

客户端把一个文件分成多个片段,并发上传到服务端;所有片段上传成功后,发送一个合并的请求,服务端将所有片段合并成一个文件。

File 对象继承 Blob 对象, Blob 对象有个 slice 方法,进行文件分割。

ts 复制代码
/** 创建切片,接受一个 File 对象和分片的大小 */
function createChunks(file, chunkSize) {
  let chunks = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    chunks.push(file.slice(i, i + chunkSize));
  }
  return chunks;
}

然后就是合并上传,最终发起合并请求。

ts 复制代码
const inputElement = document.getElementById("file");

nputElement.addEventListener("change", async function (e) {
  const file = e.target.files[0];
  const chunks = createChunks(file, 1024 * 1024);
  const randomFileName = `${Math.random().toString().slice(2, 10)}_${
    file.name
  }`;
  let tasks = [];
  chunks.forEach((chunk, index) => {
    const data = new FormData();
    data.append("name", `${randomFileName}_${index}`);
    data.append("files", chunk);
    tasks.push(axios.post("http://localhost:3000/upload/largeFile", data));
  });
  await Promise.all(tasks);
  // 分片都上传成功,发送合并请求
  axios.get(`http://localhost:3000/upload/fileMerge?name=${randomFileName}`);
});
  • randomFileName 创建一个随机文件名,不同用户上传时可能会存在重名。
  • Promise.all() 等待所有文件上传成功后,然后发送合并请求 merge

针对服务端而言,在接收到客户端上传的文件时,需要对路径进行处理。

针对未处理的路径,文件名不规则,直接放在 uploads 目录下,在合并时,无法知道哪些分片是一组,所以无法合并。

所以针对上传的文件需要路径处理及重新命名,并进行分组。

ts 复制代码
export class UploadController {
  @Post("largeFile")
  @UseInterceptors(
    FilesInterceptor("files", 200, {
      dest: "uploads",
    })
  )
  handleLargeFile(
    @UploadedFiles() files: Array<Express.Multer.File>,
    @Body() body: { name: string }
  ) {
    // 解析 name 属性
    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);
    // 移除
    fs.rmSync(files[0].path);
    return {
      data: files,
      message: "上传成功",
    };
  }
}

解析客户端传递过来的 name,然后创建一个文件夹,将上传的文件移动到文件夹下。

当客户端发送合并请求时,会通过 query 方式携带 name 属性过来(因为上面进行分组,也是根据 name 属性分组的),读取分组下面的文件,进行合并。

ts 复制代码
export class UploadController {
  @Get("fileMerge")
  handleFileMerge(@Query("name") name: string) {
    // 获取文件目录并读取里面的文件 files
    const chunkDir = "uploads/chunks_" + name;
    const files = fs.readdirSync(chunkDir);

    let startPos = 0;
    let count = 0;
    // 注意点:files 看文件的命名,可能存在乱序的,需要 sort 一下
    files.forEach((v) => {
      const chunkFilePath = chunkDir + "/" + v;
      // 创建可读流,利用管道边读边写入
      const stream = fs.createReadStream(chunkFilePath);
      stream
        .pipe(fs.createWriteStream(`uploads/${name}`, { start: startPos }))
        // 完成时,就删除分组文件夹
        .on("finish", () => {
          count++;
          if (count === files.length) {
            fs.rm(
              chunkDir,
              {
                recursive: true,
              },
              () => {}
            );
          }
        });
      // 累加文件大小,下次从这里开发写入
      startPos += fs.statSync(chunkFilePath).size;
    });
    return {
      message: "合并成功",
    };
  }
}

大文件上传的基本流程大致如此。

node fs 模块方法

在前面的示例中,涉及到了很多 fs 的方法,这里简单介绍下。

  • fs.writeFile:用于将数据写入到指定的文件中。如果文件已存在,则会被覆盖;如果文件不存在,则会被创建。(该方法是异步的,同步的方法为 fs.writeFileSync()
  • fs.mkdirSync:同步的创建文件。(异步方法:fs.mkdir()
  • fs.existsSync:判断文件是否存在
  • fs.cpSync: 同步的将源目录或文件复制到目标位置。
  • fs.rm:异步方法,用于删除文件或目录
  • fs.createReadStream: 创建可读流。
  • fs.createWriteStream: 创建可写流。
  • fs.statSync: 同步方法,用于获取文件或目录的元数据信息

总结

File 对象可以分为两部分:可见的元数据(文件的大小,类型)和不可见的文件流(二进制数据),可以通过 FileReader 来读取文件流。

通过 FormData 对象可以将文件流和元数据一起发送给服务器,但是服务器接收的流有点杂质,需要过滤掉才能正常的识别文件。

针对处理多余的内容,借助第三方库 multer 来进行处理。提供了 single 方法处理单个字段的单个文件,array 方法处理单个字段的多个文件,fields 方法处理多个字段的文件,any 处理任意数量字段的文件。

NestJS 也是使用 multer 来进行文件处理的。使用拦截器(FileInterceptor,FilesInterceptor, FileFieldsInterceptor,AnyFilesInterceptor)和装饰器(UploadedFile, UploadedFiles)来实现,可以上传多个,也可以上传单个,也可以校验文件的大小(MaxFileSizeValidator)及类型(FileTypeValidator)。

针对大文件上传,需要将文件分成多个片段,然后上传到服务器,然后再合并。

相关推荐
续亮~26 分钟前
6、Redis系统-数据结构-05-整数
java·前端·数据结构·redis·算法
顶顶年华正版软件官方2 小时前
剪辑抽帧技巧有哪些 剪辑抽帧怎么做视频 剪辑抽帧补帧怎么操作 剪辑抽帧有什么用 视频剪辑哪个软件好用在哪里学
前端·音视频·视频·会声会影·视频剪辑软件·视频剪辑教程·剪辑抽帧技巧
MarkHD2 小时前
javascript 常见设计模式
开发语言·javascript·设计模式
托尼沙滩裤3 小时前
【js面试题】js的数据结构
前端·javascript·数据结构
不熬夜的臭宝3 小时前
每天10个vue面试题(一)
前端·vue.js·面试
朝阳393 小时前
vue3【实战】来回拖拽放置图片
javascript·vue.js
不如喫茶去3 小时前
VUE自定义新增、复制、删除dom元素
前端·javascript·vue.js
长而不宰3 小时前
vue3+electron项目搭建,遇到的坑
前端·vue.js·electron
阿垚啊4 小时前
vue事件参数
前端·javascript·vue.js
加仑小铁4 小时前
【区分vue2和vue3下的element UI Dialog 对话框组件,分别详细介绍属性,事件,方法如何使用,并举例】
javascript·vue.js·ui