文件上传

文件上传的原理

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

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

而前端上传文件就是通过 <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)。

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

相关推荐
Cachel wood14 分钟前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端15 分钟前
0基础学前端-----CSS DAY9
前端·css
joan_8519 分钟前
layui表格templet图片渲染--模板字符串和字符串拼接
前端·javascript·layui
还是大剑师兰特42 分钟前
什么是尾调用,使用尾调用有什么好处?
javascript·大剑师·尾调用
m0_748236111 小时前
Calcite Web 项目常见问题解决方案
开发语言·前端·rust
Watermelo6171 小时前
详解js柯里化原理及用法,探究柯里化在Redux Selector 的场景模拟、构建复杂的数据流管道、优化深度嵌套函数中的精妙应用
开发语言·前端·javascript·算法·数据挖掘·数据分析·ecmascript
m0_748248941 小时前
HTML5系列(11)-- Web 无障碍开发指南
前端·html·html5
m0_748235611 小时前
从零开始学前端之HTML(三)
前端·html
一个处女座的程序猿O(∩_∩)O3 小时前
小型 Vue 项目,该不该用 Pinia 、Vuex呢?
前端·javascript·vue.js
hackeroink6 小时前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss