Nestjs 学习记录:(六)文件上传【分片上传&OSS上传】

477 阅读4分钟

一、引言

Nest 内置的文件上传模块基于 Express multer 中间件开发,它会处理客户端传递过来的 multipart/form-data 格式的数据,可以通过调整配置参数控制该模块的表现方式以适应项目需求。

该模块不支持 FastifyAdapter,不能处理非 multipart/form-data 格式的数据

为了获得更好的类型支持,最好安装下 multer 的声明文件。安装完成后,我们可以从 express 中【Nestjs 已经全局声明了 Express,也可以不引入直接使用】导入 Express.Multer.File ,用来标注文件的类型。

$ npm i -D @types/multer

二、Multer 指南

Multer 是一个 nodejs 的中间件,通常用于处理 multipart/form-data 格式的数据

(一)文件API

经过 Multer 处理后的文件通常包含下列字段:

KeyDesc
fieldnameFormData 中文件对应的字段名称
originalname上传时的文件名
encoding编码方式
mimetypeMimeType
size字节数
destination存储路径
filename存储时的文件名
path完整的存储路径
buffer文件 Buffer

(二)Multer Options

Multer 会接收一个配置项对象,其中包含多个属性,比如 dest ,它会告诉 Multer 将文件上传到存储目录,如果忽略了该属性,文件仅会被存储在缓存而不会写入到磁盘。

默认情况下,Multer 为了避免命名冲突,会对文件进行重命名,重命名函数也可以根据用户需求自己定义

以下是可以传递给 Multer 的配置项对象:

KeyDesc
dest or storage目标存储路径
fileFilter控制哪些文件可以被接收的函数
limits上传数据的限制条件
preservePath保存文件的完整路径,而不是基本的文件名

在绝大多数 web 应用中,可能仅有 dest 属性是必须的:

const upload = multer({ dest: 'uploads/' })

如果你希望对上传操作有更多的控制权,可以使用 storage 选项替代,Multer 自带 DiskStorage 和 MemoryStorage 存储引擎供开发者使用

.single(fieldname)

接收单个文件,通常被存储在 req.file 中

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 的限制,可能会抛出异常

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【最大数量】 属性,示例如下:

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
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 装饰器。

@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… 

KeyDescription
dest or storage文件的存储位置
fileFilter判断是否接收传来的文件的函数
limits对上传的数据的限制用配置项,包括 fileSize 等
preservePath保存文件的完整路径而不是仅文件名

(二)File Validation

在 Nest 中,大多数与 Validation 相关的操作,都可以使用 Pipe 来实现。

很多时候,我们会需要对客户端上传的文件校验其元数据,比如 fileSize、mimeType 等。Nest 推荐使用 Pipe 执行校验逻辑,将其作为参数传递到 @UploadedFile() 装饰器中

方法一:自定义 Pipe

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 :

@UploadedFile(
  new ParseFilePipe({
    validators: [
      // 校验文件的尺寸
      new MaxFileSizeValidator({ maxSize: 1000 }),
      // 校验文件的 MimeType
      new FileTypeValidator({ fileType: 'image/jpeg' }),
    ],
  }),
)
file: Express.Multer.File,

如果你想自定义校验函数,可以通过继承 FileValidator 并实现内部的 isValidbuildErrorMessage 方法

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

相较于上述的方法,它可以让我们免于手动实例化每个校验器,支持自由组合校验流程,只需要在使用时传递对应的配置项即可。

@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 相同
@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 字段的值不能重复

@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) 生成文件存储路径

function genUploadDir(user: UserTokenEntity) {
    const uploadDir = join(
        process.cwd(),
        `/files/${user.username}/file/`
    )
    if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true })
    return uploadDir
}

(2) 生成切片存储路径

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) 查询所有已上传的切片

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
  • MethodPOST

请求参数

参数类型约束
filenameString文件名
filehashString文件哈希

逻辑代码

// file.dto.ts
export interface VerifyOptions {
  filename: string
  filehash: string
}
@Post('/verify')
handleVerify(@Req() req: Request, @Body() verifyOptions: VerifyOptions) {
    return this.fileService.handleVerify(req['user'], verifyOptions)
}
/**
 * @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
  • MethodPOST

请求参数

参数类型约束
chunkBlob文件切片
hashString切片哈希
filenameString文件名
filehashString文件哈希

逻辑代码

export interface ChunkOptions {
  hash: string
  filename: string
  filehash: string
}
@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
    )
}
/**
 * @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
  • MethodPOST

请求参数

参数类型约束
sizeBlob文件切片
filenameString文件名
filehashString文件哈希

逻辑代码

export interface MergeOptions {
  size: number
  filename: string
  filehash: string
}
@Post('/merge')
function handleMerge(
    @Req() req: Request,
    @Body() mergeOptions: MergeOptions
) {
    return this.fileService.handleMerge(req['user'], mergeOptions)
}
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)
}
/**
* @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

image-20240311091158804.png

需要注意的是,Bucket 的名称必须符合 Alibaba Cloud OSS 规范:

  1. 只能包括小写字母、数字和短横线(-)
  2. 必须以小写字母或数字开头和结尾
  3. 长度必须在 3-63 字节之间
  4. 不能是连续多个短横线,也不能用IP地址形式的字符串,例如 "10.1.1.1"

image.png

创建完成后,我们就有了一个名为 meleon-profile-oss 的 Bucket,之后的测试中,我们会将文件上传到这个 bucket 中。

2. 配置项

初始化 OSS 客户端,首先需要提供下列配置信息:

const OSSConfig: OSS.Options = {
    accessKeyId: '',
    accessKeySecret: '',
    // Bucket 名称
    bucket: 'meleon-profile-oss',
    // Bucket 所在地域
    region: 'oss-cn-hangzhou'
}

注意: 用户的 AccessKey ID 和 AccessKey Secret 属于敏感信息,尽量避免明文写在自己的代码里,建议执行一次加密操作

3. 使用方式

将我们先前定义好的 OSSConfig 以及解密后的 AccessKey 作为参数实例化 OSS

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 方法则是用于设置文件的读写权限

/**
  * @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 为文件存储在磁盘上的路径

/**
  * @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
}

测试上传

image.png

(2)下载文件

使用 ali-oss 的getStream下载文件时,返回的Readable Stream用于流式地处理文件内容。

服务端代码

// 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
    }
}

前端代码

// 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)
    }
}

测试下载

image.png

五、总结

文件上传是我们日常开发中经常会遇到的功能点,以上,我们分别了解了 Multer 以及 Nestjs 文件上传模块的相关知识,并基于这部分知识实现了文件切片上传OSS上传功能。

当然,受限于篇幅,文中涉及的代码还是以服务端 Nestjs 的代码为主,前端代码偏少,以后有时间的话再写个相对完善的功能放到 github 上。