文件上传的原理
计算机中所有的内容:文字、数字、图片、音频等最终都会使用二进制来表示。
针对服务器来说,文件上传就是读取客户端传递过来的文件流,生成新的文件并保存在磁盘中。
而前端上传文件就是通过 <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 中提供了校验器:
MaxFileSizeValidator
和FileTypeValidator
。常用的也是这两个。 - 当发送不合适的文件,就会抛出异常。
配置上传目录及文件命名
定义 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
)。
针对大文件上传,需要将文件分成多个片段,然后上传到服务器,然后再合并。