一、引言
Nest 内置的文件上传模块基于 Express multer 中间件开发,它会处理客户端传递过来的 multipart/form-data
格式的数据,可以通过调整配置参数控制该模块的表现方式以适应项目需求。
该模块不支持 FastifyAdapter,不能处理非
multipart/form-data
格式的数据
为了获得更好的类型支持,最好安装下 multer 的声明文件。安装完成后,我们可以从 express
中【Nestjs 已经全局声明了 Express,也可以不引入直接使用】导入 Express.Multer.File
,用来标注文件的类型。
shell
$ npm i -D @types/multer
二、Multer 指南
Multer 是一个 nodejs 的中间件,通常用于处理 multipart/form-data 格式的数据
(一)文件API
经过 Multer 处理后的文件通常包含下列字段:
Key | Desc | |
---|---|---|
fieldname |
FormData 中文件对应的字段名称 | |
originalname |
上传时的文件名 | |
encoding |
编码方式 | |
mimetype |
MimeType | |
size |
字节数 | |
destination |
存储路径 | |
filename |
存储时的文件名 | |
path |
完整的存储路径 | |
buffer |
文件 Buffer |
(二)Multer Options
Multer
会接收一个配置项对象,其中包含多个属性,比如 dest
,它会告诉 Multer 将文件上传到存储目录,如果忽略了该属性,文件仅会被存储在缓存而不会写入到磁盘。
默认情况下,Multer 为了避免命名冲突,会对文件进行重命名,重命名函数也可以根据用户需求自己定义
以下是可以传递给 Multer 的配置项对象:
Key | Desc |
---|---|
dest or storage |
目标存储路径 |
fileFilter |
控制哪些文件可以被接收的函数 |
limits |
上传数据的限制条件 |
preservePath |
保存文件的完整路径,而不是基本的文件名 |
在绝大多数 web 应用中,可能仅有 dest 属性是必须的:
ts
const upload = multer({ dest: 'uploads/' })
如果你希望对上传操作有更多的控制权,可以使用 storage
选项替代,Multer 自带 DiskStorage 和 MemoryStorage 存储引擎供开发者使用
.single(fieldname)
接收单个文件,通常被存储在 req.file 中
ts
const multer = require('multer')
const upload = multer({ dest: './public/data/uploads/' })
app.post('/singleFile', upload.single('avatar'), function (req, res) {
/*
{
fieldname: 'avatar',
originalname: 'room2.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: 'uploads/',
filename: 'b869a1f19b193422983d76c5ed1a0ee6',
path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
size: 5409826
}
*/
console.log(req.file)
});
.array(fieldname[, maxCount])
接收一个文件数组,通常被存储在 req.files 中,如果上传的文件数超出了 maxCount 的限制,可能会抛出异常
ts
app.post('/multiFiles', upload.array('photos', 12), function (req, res, next) {
/*
[
{
fieldname: 'photos',
originalname: 'room2.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: 'uploads/',
filename: 'b869a1f19b193422983d76c5ed1a0ee6',
path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
size: 5409826
},
{
fieldname: 'photos',
originalname: '组 14.png',
encoding: '7bit',
mimetype: 'image/png',
destination: 'uploads/',
filename: '5ef6099af79d1e253adc4559972ea675',
path: 'uploads\5ef6099af79d1e253adc4559972ea675',
size: 11093
},
...
]
*/
console.log(req.files)
})
.fields(fields)
接收多个字段,每个字段对应多份文件,并存储在 req.files 中,fields 需要是一个对象数组,包含 name【字段名】 和 maxCount【最大数量】 属性,示例如下:
ts
const cpUpload = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 8 }
])
router.post('/multiFields', cpUpload, function (req, res, next) {
/*
{
avatar: [
{
fieldname: 'photos',
originalname: 'room2.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: 'uploads/',
filename: 'b869a1f19b193422983d76c5ed1a0ee6',
path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
size: 5409826
}
]
gallery: []
}
*/
console.log(req.files)
})
.diskStorage
javascript
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, '/tmp/my-uploads')
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9)
cb(null, file.fieldname + '-' + uniqueSuffix)
}
})
const upload = multer({ storage: storage })
diskstorage
提供了两个可用的配置项:destination
and filename
。
destination
用于指明通过接口上传的文件应当被存储在哪个文件夹下,如果为提供目标文件夹,则会使用操作系统默认的目录。
Note: 确保你已经创建好目标文件夹
filename
用于指明文件存储时用的文件名,如果未提供该函数,multer 会为文件提供随机的文件名
Note: Multer 不会添加为你的文件任何扩展名,因此在保存时需要提供完整的文件名
三、Nest 文件上传指南
(一)Basic Example
我们在 file.controller.ts
中创建一个新的 post 接口用来处理单文件上传功能,需要用到 FileInterceptor
拦截器以及 @UploadedFile
装饰器。
ts
@Controller('file')
export class FileController {
@Post('uploadFile')
@UseInterceptors(FileInterceptor('file'))
uploadBlog(@UploadedFile() file: Express.Multer.File) {
console.log(file)
}
}
@UploadedFile() file: Express.Multer.File
装饰器:从请求对象 request
中提取出 file
FileInterceptor(fieldName, options?)
拦截器
-
fieldName:字符串,表示 formData 中对应文件内容的字段名
-
options:可选配置项 MulterOptions,与 Multer 配置项相同
[multer] github.com/expressjs/m...
Key | Description |
---|---|
dest or storage |
文件的存储位置 |
fileFilter |
判断是否接收传来的文件的函数 |
limits |
对上传的数据的限制用配置项,包括 fileSize 等 |
preservePath |
保存文件的完整路径而不是仅文件名 |
(二)File Validation
在 Nest 中,大多数与 Validation 相关的操作,都可以使用 Pipe 来实现。
很多时候,我们会需要对客户端上传的文件校验其元数据,比如 fileSize、mimeType 等。Nest 推荐使用 Pipe 执行校验逻辑,将其作为参数传递到 @UploadedFile() 装饰器中
方法一:自定义 Pipe
ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class FileSizeValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// "value" 中会包含文件的元数据信息
const oneKb = 1000;
// 文件尺寸在 1KB 以下时返回文件本身
return value.size < oneKb && value;
}
}
方法二:Nest 内置的模块 -- ParseFilePipe
以一种标准化的方式处理常见的校验用例,它需要我们提供一个包含文件校验类的数组供 ParseFilePipe
执行。
Nest 有提供两个内置的校验函数供开发者使用,分别用于校验文件的大小及MimeType :
ts
@UploadedFile(
new ParseFilePipe({
validators: [
// 校验文件的尺寸
new MaxFileSizeValidator({ maxSize: 1000 }),
// 校验文件的 MimeType
new FileTypeValidator({ fileType: 'image/jpeg' }),
],
}),
)
file: Express.Multer.File,
如果你想自定义校验函数,可以通过继承 FileValidator
并实现内部的 isValid
和 buildErrorMessage
方法
ts
export abstract class FileValidator<TValidationOptions = Record<string, any>> {
constructor(protected readonly validationOptions: TValidationOptions) {}
/**
* 根据传递的校验选项,判断当前文件是否合法
* @param file:用户上传的文件
*/
abstract isValid(file?: any): boolean | Promise<boolean>;
/**
* 校验失败后,创建报错信息
* @param file:用户上传的文件
*/
abstract buildErrorMessage(file: any): string;
}
方法三:ParseFilePipeBuilder
相较于上述的方法,它可以让我们免于手动实例化每个校验器,支持自由组合校验流程,只需要在使用时传递对应的配置项即可。
ts
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({
fileType: 'jpeg',
})
.addMaxSizeValidator({
maxSize: 1000
})
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY
}),
)
file: Express.Multer.File,
(三)Array of Files
使用单字段 上传多文件 时,可以使用 FilesInterceptor
【注意,这里用的是 Files】处理。
该拦截器需要三个参数:
fieldName
: 字段名maxCount
: 可选,控制可接收的最大文件数options
: 可选,与MulterOptions
相同
ts
@Controller('file')
export class BlogController {
@Post('uploadFiles')
@UseInterceptors(FilesInterceptor('files'))
uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>) {
console.log(files)
}
}
注意:FilesInterceptor 和 UploadedFiles 中的是 Files 而不是 File
(四)Multiple Files
使用不同字段上传多个文件,需要使用 FileFieldsInterceptor
处理。该拦截器需要两个参数:
uploadedFields
: 对象数组,每个对象需要指定name
属性声明字段名,以及一个可选参数 maxCount 控制最大文件数options
: 可选,与MulterOptions
一致
注意:name 字段的值不能重复
ts
@Post('upload')
@UseInterceptors(FileFieldsInterceptor([
{ name: 'avatar', maxCount: 1 },
{ name: 'background', maxCount: 1 },
]))
uploadFile(@UploadedFiles() files: { avatar?: Express.Multer.File[], background?: Express.Multer.File[] }) {
console.log(files);
}
四、功能实战
(一)大文件分片上传接口设计
1. 通用函数
(1) 生成文件存储路径
ts
function genUploadDir(user: UserTokenEntity) {
const uploadDir = join(
process.cwd(),
`/files/${user.username}/file/`
)
if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true })
return uploadDir
}
(2) 生成切片存储路径
ts
function genChunkDir(user: UserTokenEntity, filehash: string) {
const chunkDir = join(
this.genUploadDir(user),
`/chunks/chunkDir_${filehash}`
)
if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true })
return chunkDir
}
(3) 查询所有已上传的切片
ts
async function createUploadedList(user: UserTokenEntity, filehash: string) {
const chunkDir = this.genChunkDir(user, filehash)
return existsSync(chunkDir)
? await readdirSync(chunkDir)
: []
}
2. /verify
校验接口
校验是否需要上传,如果需要,返回已上传的切片 ID 列表
- URL :
/api/file/verify
- Method :
POST
请求参数
参数 | 类型 | 约束 |
---|---|---|
filename | String | 文件名 |
filehash | String | 文件哈希 |
逻辑代码
ts
// file.dto.ts
export interface VerifyOptions {
filename: string
filehash: string
}
ts
@Post('/verify')
handleVerify(@Req() req: Request, @Body() verifyOptions: VerifyOptions) {
return this.fileService.handleVerify(req['user'], verifyOptions)
}
ts
/**
* @description 校验是否允许上传,以及允许哪些部分上传
* @param user
* @param verifyOptions
* @returns
*/
async function handleVerify(
user: UserTokenEntity,
verifyOptions: VerifyOptions
) {
try {
const { username } = user
const { filename, filehash } = verifyOptions
const filePath = resolve(this.genUploadDir(user), filename)
// 判断文件是否已经存在
if (existsSync(filePath)) {
this.response = getFailResponse(
'The file has already been uploaded',
null
)
this.logger.error(
'/file/uploadFile',
`${username}已经上传过${filename}了`
)
} else {
this.response = getSuccessResponse(
'verification succeed, allow to upload',
{
shouldUpload: true,
// 前端获取到已经上传的切片列表后,可以过滤掉已上传的切片,实现秒传或断点续传效果
uploadedList: await this.createUploadedList(user, filehash)
}
)
this.logger.info(
'/file/uploadFile',
`${username}上传${filename}的校验已通过,允许上传文件切片`
)
}
} catch (err) {
this.response = getFailResponse('文件上传失败', null)
this.logger.error('/file/uploadFile', `文件上传失败,失败原因:${err}`)
}
return this.response
}
3. /uploadFileChunk
切片上传接口
上传文件切片
- URL :
/api/file/uploadFileChunk
- Method :
POST
请求参数
参数 | 类型 | 约束 |
---|---|---|
chunk | Blob | 文件切片 |
hash | String | 切片哈希 |
filename | String | 文件名 |
filehash | String | 文件哈希 |
逻辑代码
ts
export interface ChunkOptions {
hash: string
filename: string
filehash: string
}
ts
@Post('/uploadFileChunk')
@UseInterceptors(FileInterceptor('chunk'))
function handleFileSlice(
@Req() req: Request,
@UploadedFile() chunk: Express.Multer.File,
@Body() chunkOptions: ChunkOptions
) {
return this.fileService.handleChunkUpload(
req['user'],
chunk,
chunkOptions
)
}
ts
/**
* @description 上传切片到指定目录
* @param user
* @param chunk 文件切片数据
* @param chunkOptions 文件及切片相关数据
* @returns
*/
async function handleChunkUpload(
user: UserTokenEntity,
chunk: Express.Multer.File,
chunkOptions: ChunkOptions
) {
const { hash, filename, filehash } = chunkOptions
try {
const filePath = resolve(this.genUploadDir(user), filename)
// 先创建临时文件夹用于临时存储文件切片
const chunkDir = this.genChunkDir(user, filehash)
const chunkPath = resolve(chunkDir, hash)
// 文件已存在,直接返回
if (existsSync(filePath)) {
this.response = getSuccessResponse(
'File has already existed in server',
filename
)
this.logger.info(
'/file/uploadFileChunk',
`${user.username} 无需上传文件${filename},文件已存在`
)
return this.response
}
// 存放 chunk 的目录不存在,创建目录
if (!existsSync(chunkDir)) {
await mkdirSync(chunkDir)
}
// 切片已存在,直接返回
if (existsSync(chunkPath)) {
this.response = getSuccessResponse(
'Chunk has already been uploaded',
hash
)
this.logger.info(
'/file/uploadFileChunk',
`${user.username} 无需上传文件${filename}的切片,切片hash:${hash}`
)
return this.response
}
await writeFileSync(chunkPath, chunk.buffer)
this.response = getSuccessResponse(
'File Chunk Has Been Received',
hash
)
this.logger.info(
'/file/uploadFileChunk',
`${user.username} 上传文件${filename}切片成功,切片hash:${hash}`
)
} catch (error) {
this.response = getFailResponse('Chunk upload failed', null)
this.logger.error(
'/file/uploadFileChunk',
`${user.username} 上传文件切片失败,失败原因:${error}`
)
}
return this.response
}
4. /merge
合并切片
合并上传的切片存储为文件,并删除暂存的切片
- URL :
/api/file/merge
- Method :
POST
请求参数
参数 | 类型 | 约束 |
---|---|---|
size | Blob | 文件切片 |
filename | String | 文件名 |
filehash | String | 文件哈希 |
逻辑代码
ts
export interface MergeOptions {
size: number
filename: string
filehash: string
}
ts
@Post('/merge')
function handleMerge(
@Req() req: Request,
@Body() mergeOptions: MergeOptions
) {
return this.fileService.handleMerge(req['user'], mergeOptions)
}
ts
function pipeStream(path: string, writeStream: WriteStream) {
return new Promise(resolve => {
const readStream = createReadStream(path)
readStream.on('end', () => {
unlinkSync(path)
resolve('')
})
readStream.pipe(writeStream)
})
}
async function mergeFileChunk(
user: UserTokenEntity,
filePath: string,
filehash: string,
size: number
) {
const chunkDir = this.genChunkDir(user, filehash)
const chunkPaths: string[] = readdirSync(chunkDir)
// 根据切片下标进行排序
// 否则直接读取目录的获得的顺序会错乱
chunkPaths.sort(
(a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1])
)
// 并发写入文件
await Promise.all(
chunkPaths.map((chunkPath, index) =>
this.pipeStream(
resolve(chunkDir, chunkPath),
// 根据 size 在指定位置创建可写流
createWriteStream(filePath, {
start: index * size
})
)
)
)
// 合并后删除保存切片的目录
await rmdirSync(chunkDir)
}
ts
/**
* @description 合并文件切片并存储至指定目录
* @param user
* @param mergeOptions
*/
async function handleMerge(
user: UserTokenEntity,
mergeOptions: MergeOptions
) {
const { filehash, filename, size } = mergeOptions
try {
await this.mergeFileChunk(
user,
resolve(this.genUploadDir(user), filename),
filehash,
size
)
this.response = getSuccessResponse(
'文件合并成功',
filename
)
this.logger.info(
'/file/merge',
`${user.username}上传文件${filename},执行合并操作成功`
)
} catch (err) {
this.response = getFailResponse('文件合并失败', null)
this.logger.error(
'/file/merge',
`${user.username}上传文件${filename},执行合并操作失败,失败原因:${err}`
)
}
return this.response
}
(二)对接阿里云 OSS 对象存储
1. 前提条件
- 已开通阿里云对象存储 OSS 服务器
- 已创建 RAM 用户的 AccessKey ID 和 AccessKey Secret【可以在阿里云账号中心的 AccessKey 管理中创建】
1. 创建 bucket
需要注意的是,Bucket 的名称必须符合 Alibaba Cloud OSS 规范:
- 只能包括小写字母、数字和短横线(-)
- 必须以小写字母或数字开头和结尾
- 长度必须在 3-63 字节之间
- 不能是连续多个短横线,也不能用IP地址形式的字符串,例如 "10.1.1.1"
创建完成后,我们就有了一个名为 meleon-profile-oss
的 Bucket,之后的测试中,我们会将文件上传到这个 bucket 中。
2. 配置项
初始化 OSS 客户端,首先需要提供下列配置信息:
ts
const OSSConfig: OSS.Options = {
accessKeyId: '',
accessKeySecret: '',
// Bucket 名称
bucket: 'meleon-profile-oss',
// Bucket 所在地域
region: 'oss-cn-hangzhou'
}
注意: 用户的 AccessKey ID 和 AccessKey Secret 属于敏感信息,尽量避免明文写在自己的代码里,建议执行一次加密操作
3. 使用方式
将我们先前定义好的 OSSConfig 以及解密后的 AccessKey 作为参数实例化 OSS
ts
import * as OSS from 'ali-oss'
import OSSConfig from './constants/oss.constant'
class OssService {
client: OSS
constructor(private readonly configService: ConfigService) {
this.client = new OSS({
...OSSConfig,
// 解密存放在环境变量中的 Key
accessKeyId: DecryptPrivateInfo(
this.configService.get('NEST_OSS_ID')
),
accessKeySecret: DecryptPrivateInfo(
this.configService.get('NEST_OSS_SECRET')
)
})
}
}
(1)上传文件
上传文件至阿里云对象存储服务器中,主要涉及两个方法:OSS.put 和 OSS.putACL
OSS.put 方法会将文件上传至对象存储服务器中
OSS.putACL 方法则是用于设置文件的读写权限
ts
/**
* @description 上传文件到 OSS 并返回文件地址
* @param ossPath
* @param localPath
* @returns
*/
async function putOssFile(ossPath: string, localPath: string) {
try {
const res = await this.client.put(ossPath, localPath)
await this.client.putACL(ossPath, 'public-read')
console.log(res)
return res.url
} catch (err) {
console.log('oss', err)
throw err
}
}
上传功能需要提供 OssPath 和 filePath
OssPath 为文件存储在对象存储器上的文件路径
filePath 为文件存储在磁盘上的路径
ts
/**
* @description 将文件上传至阿里云OSS对象存储
* @param user 用户信息
* @param file 文件
* @returns
*/
async function uplodaFileToOSS(user: UserTokenEntity, file: Express.Multer.File) {
const username = user?.username ?? 'meleon'
const filename = file.originalname
try {
const ossUrl = await this.ossService.putOssFile(`/${username}/${filename}`, file.path)
this.response = getSuccessResponse('文件上传成功', ossUrl)
this.logger.info(
'/file/oss/uploadFile',
`${username}上传文件[${filename}]至 Aliyun OSS 成功,文件地址为 ${ossUrl}`
)
} catch (err) {
this.response = getFailResponse('文件合并失败', null)
this.logger.error(
'/file/oss/uploadFile',
`${username}上传文件[${filename}]失败,失败原因:${err}`
)
}
return this.response
}
测试上传
(2)下载文件
使用 ali-oss 的getStream
下载文件时,返回的Readable Stream
用于流式地处理文件内容。
服务端代码
ts
// file.controller.ts
@Get('/oss/downloadFile')
function handleDownloadFile(@Query('path') path: string) {
return this.fileService.downloadFileFromOSS(path)
}
// file.service.ts
async function downloadFileFromOSS(path: string) {
try {
const res = await this.ossService.downloadFileStream(path)
const storagePath = genStoragePath(`/oss/${path}`)
if (res && existsSync(storagePath)) {
const readStream = createReadStream(storagePath)
const streamableFile = new StreamableFile(readStream)
readStream.on('end', () => {
this.cleanUpFile(storagePath)
})
readStream.on('error', () => {
this.cleanUpFile(storagePath)
})
return streamableFile
} else {
return '失败了'
}
} catch (err) {
console.log(err)
return '失败了'
}
}
// oss.service.ts
/**
* @description 将 OSS 上的文件下载到本地
* @param path 文件存储在 OSS 服务器上的路径
* @returns true | false 下载是否成功
*/
async function downloadFileStream(path: string) {
try {
const result = await this.client.getStream(path)
const folders = path.split('/')
const filename = folders.pop()
const targetFolder = join(
process.cwd(),
'/files/oss',
folders.join('/')
)
await new Promise((resolve, reject) => {
if (!existsSync(targetFolder)) mkdirSync(targetFolder, { recursive: true })
const writeStream = createWriteStream(join(targetFolder, filename))
result.stream.pipe(writeStream)
result.stream.on('error', () => {
reject(false)
})
writeStream.on('finish', () => {
resolve(true)
})
})
return true
} catch (err) {
console.log(err)
return false
}
}
前端代码
ts
// api
export const DownloadFile = (path: string) => {
return request.get<Blob>(URLs.download, {
params: { path },
responseType: 'blob'
})
}
// page
function handleDownload(path: string) {
// '/meleon/7c70c0a5e8266637ded218cd39bd5be.jpg'
const { data: blob } = await DownloadFile(path)
if (blob) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '7c70c0a5e8266637ded218cd39bd5be.jpg'
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
}
}
测试下载
五、总结
文件上传是我们日常开发中经常会遇到的功能点,以上,我们分别了解了 Multer 以及 Nestjs 文件上传模块的相关知识,并基于这部分知识实现了文件切片上传 和OSS上传功能。
当然,受限于篇幅,文中涉及的代码还是以服务端 Nestjs 的代码为主,前端代码偏少,以后有时间的话再写个相对完善的功能放到 github 上。