上传文件
Nest内置了Express的 Multer 中间件,它只处理 multipart/form-data
类型的表单数据,主要用于上传文件。文档
类型提示:npm i -D @types/multer
,接着就可以使用 Express.Multer.File
类型,表示上传的文件信息
1 2 3 4 5 6 7 8 9 10 11 12
| interface File { fieldname: string; originalname: string; encoding: string; mimetype: string; size: number; stream: Readable; destination: string; filename: string; path: string; buffer: Buffer; }
|
单文件上传
@nestjs/platform-express
提供了 FileInterceptor()
拦截器处理单文件上传
1 2 3 4
| function FileInterceptor( fieldName: string, localOptions?: MulterOptions ): Type<NestInterceptor>;
|
Multer的配置项:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| interface MulterOptions { dest?: string | Function; storage?: any; limits?: { fieldNameSize?: number; fieldSize?: number; fields?: number; fileSize?: number; files?: number; parts?: number; headerPairs?: number; }; preservePath?: boolean; fileFilter?( req: any, file: { fieldname: string; originalname: string; encoding: string; mimetype: string; size: number; destination: string; filename: string; path: string; buffer: Buffer; }, callback: ( error: Error | null, acceptFile: boolean ) => void ): void; }
|
在路由方法上应用该拦截器,再使用 @UploadedFile()
参数装饰器获取 Express.Multer.File
类型文件对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Post('img') @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: './public/img', filename: (req, file, cb) => { const fileParse = path.parse(file.originalname); const fileName = `${fileParse.name}_${new Date().getTime()}${fileParse.ext}`; return cb(null, fileName); }, }), }), ) imgUpload(@UploadedFile() file: Express.Multer.File) { return file; }
|
多文件上传-数组
使用 FilesInterceptor()
拦截器处理多文件上传,一个字段上传多个文件。
1 2 3 4 5
| function FilesInterceptor( fieldName: string, maxCount?: number, localOptions?: MulterOptions ): Type<NestInterceptor>;
|
使用 @UploadedFiles()
参数装饰器获取 Express.Multer.File[]
类型文件对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Post('images') @UseInterceptors( FilesInterceptor('files', 10, { storage: diskStorage({ destination: './public/img', filename: (req, file, cb) => { const fileParse = path.parse(file.originalname); const fileName = `${fileParse.name}_${new Date().getTime()}${fileParse.ext}`; return cb(null, fileName); }, }), }), ) imagesUpload(@UploadedFiles() files: Express.Multer.File[]) { console.log(files); return 'success'; }
|
MulterModule统一配置
多个路由都需要上传文件,可以将相同配置提取出来,在模块的 imports 中使用 MulterModule.register()
导入配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Module({ imports: [ MulterModule.register({ storage: diskStorage({ destination: './public/img', filename: (req, file, cb) => { const fileParse = path.parse(file.originalname); const fileName = `${fileParse.name}_${new Date().getTime()}${fileParse.ext}`; return cb(null, fileName); }, }), }), ], controllers: [UploadController], providers: [UploadService], }) export class UploadModule {}
|
1 2 3 4 5 6 7 8 9 10 11
| @Post('img') @UseInterceptors(FileInterceptor('file')) imgUpload(@UploadedFile() file: Express.Multer.File) { return file; }
@Post('images') @UseInterceptors(FilesInterceptor('files')) imagesUpload(@UploadedFiles() files: Express.Multer.File[]) { return files; }
|
任意字段上传
使用 AnyFilesInterceptor()
任意字段上传,请求体中任意字段上传的文件都会被处理
1 2 3 4 5
| @Post('any') @UseInterceptors(AnyFilesInterceptor()) anyUpload(@UploadedFiles() files: Express.Multer.File[]) { return files; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| [ { "fieldname": "aa", "originalname": "2.jpg", "encoding": "7bit", "mimetype": "image/jpeg", "destination": "./public/img", "filename": "2_1706690551808.jpg", "path": "public\\img\\2_1706690551808.jpg", "size": 1127874 }, { "fieldname": "bb", "originalname": "5.jpg", "encoding": "7bit", "mimetype": "image/jpeg", "destination": "./public/img", "filename": "5_1706690551818.jpg", "path": "public\\img\\5_1706690551818.jpg", "size": 1473642 }, { "fieldname": "cc", "originalname": "8.jpg", "encoding": "7bit", "mimetype": "image/jpeg", "destination": "./public/img", "filename": "8_1706690551825.jpg", "path": "public\\img\\8_1706690551825.jpg", "size": 433466 } ]
|
自定义字段上传
在多个字段上传单个或多个文件,使用 FileFieldsInterceptor()
拦截器,指定字段名和最大文件数量,允许携带其他参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Post('files') @UseInterceptors( FileFieldsInterceptor([ { name: 'avatar', maxCount: 1 }, { name: 'background', maxCount: 2 }, ]), ) uploadFile( @UploadedFiles() files: { avatar?: Express.Multer.File[]; background?: Express.Multer.File[]; }, ) { return files; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| { "avatar": [ { "fieldname": "avatar", "originalname": "5.jpg", "encoding": "7bit", "mimetype": "image/jpeg", "destination": "./public/img", "filename": "5_1706691247632.jpg", "path": "public\\img\\5_1706691247632.jpg", "size": 1473642 } ], "background": [ { "fieldname": "background", "originalname": "6596.png", "encoding": "7bit", "mimetype": "image/png", "destination": "./public/img", "filename": "6596_1706691247641.png", "path": "public\\img\\6596_1706691247641.png", "size": 1328729 }, { "fieldname": "background", "originalname": "14.jpg", "encoding": "7bit", "mimetype": "image/jpeg", "destination": "./public/img", "filename": "14_1706691247648.jpg", "path": "public\\img\\14_1706691247648.jpg", "size": 888767 } ] }
|
存在意外字段会返回错误信息
1 2 3 4 5
| { "message": "Unexpected field", "error": "Bad Request", "statusCode": 400 }
|
没有文件
要接受multipart/form-data但不允许上传任何文件,需使用NoFilesInterceptor()
,会将多部分数据设置为请求主体的属性。如果请求中包含文件,将返回BadRequestException异常
1 2 3 4 5
| @Post('noFile') @UseInterceptors(NoFilesInterceptor()) noFile(@Body() body: any) { console.log(body); }
|
静态资源目录
设置静态目录以访问上传的图片等文件
NestFactory.create<T>
泛型设置为 NestExpressApplication
,表示创建一个基于Express的应用
app.useStaticAssets()
设置静态资源目录
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| useStaticAssets(path: string, options?: ServeStaticOptions): this; interface ServeStaticOptions { dotfiles?: string; etag?: boolean; extensions?: string[]; fallthrough?: boolean; immutable?: boolean; index?: boolean | string | string[]; lastModified?: boolean; maxAge?: number | string; redirect?: boolean; setHeaders?: (res: any, path: string, stat: any) => any; prefix?: string; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| dotfiles(点文件): 说明: 设置在遇到点文件时如何处理。点文件是以点(".")开头的文件或目录。注意,此检查仅在路径本身进行,而不检查路径在磁盘上是否实际存在。 取值: 'allow': 对点文件没有特殊处理。 'deny': 对任何对点文件的请求返回403。 'ignore': 假装点文件不存在,然后调用next()。
etag(实体标签): 说明: 启用或禁用ETag生成,默认为true。 取值: true: 启用ETag生成。 false: 禁用ETag生成。
extensions(文件扩展名): 说明: 设置文件扩展名的回退。当文件不存在时,将给定的扩展名添加到文件名中并搜索。将返回找到的第一个文件。例如:['html', 'htm']。 默认值: false。
fallthrough(错误是否穿透): 说明: 让客户端错误穿透为未处理的请求,否则转发客户端错误。 默认值: false。
immutable(是否启用不可变指令): 说明: 启用或禁用Cache-Control响应头中的不可变指令。如果启用,应该同时指定maxAge选项以启用缓存。 取值: true: 启用不可变指令。
index(默认索引文件): 说明: 默认情况下,该模块将在目录请求时发送"index.html"文件。可以通过设置为false来禁用,或者提供一个字符串或字符串数组以指定新的索引文件。 默认值: true。
lastModified(最后修改时间): 说明: 启用或禁用Last-Modified头,默认为true。使用文件系统的最后修改值。 默认值: true。
maxAge(最大缓存时间): 说明: 提供http缓存的最大时间(毫秒),默认为0。也可以是ms模块接受的字符串。 默认值: 0。
redirect(重定向斜杠): 说明: 当路径名是目录时,是否重定向到带有斜杠的路径。默认为true。 默认值: true。
setHeaders(设置自定义头): 说明: 在响应上设置自定义头的函数。更改头需要同步进行。该函数调用格式为fn(res, path, stat),其中参数为: res - 响应对象。 path - 正在发送的文件路径。 stat - 正在发送的文件的stat对象。
prefix(虚拟路径前缀): 说明: 创建虚拟路径前缀。
|
1 2
| app.useStaticAssets(join(__dirname, '..', 'public'));
|
复制assets
在 nest-cli.json
中配置 compilerOptions.assets
,编译时将静态资源复制到输出目录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| "compilerOptions": { "deleteOutDir": true, "watchAssets": true, "assets": [ { "include": "../public", "outDir": "dist/public" }, { "include": "../views", "outDir": "dist/views" } ], }
|
返回/下载文件
既然上传了文件,那返回文件也是常用的
sendFile
express的sendFile
封装了下面的过程,属于流式传输。
1 2 3
| const writeStream = fs.createReadStream(filePath) stream.pipe(response)
|
会根据文件的扩展名设置Content-Type,浏览器会直接打开文件,而不是下载。
1 2 3 4
| @Get('img') fileDownload(@Query('name') name: string, @Res() res: Response) { res.sendFile(name, { root: 'public/img' }); }
|
StreamableFile
手动操作res可能会让Nest丢失对响应的控制权,让拦截器等不生效
StreamableFile 是Nest流式文件的包装器,允许在响应中返回流式传输的文件。
但它并不会自动根据文件类型设置Content-Type,默认是application/octet-stream
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Get('stream') async streamDownload( @Query('name') name: string, @Res({ passthrough: true }) res: Response, ) { const fd = await fs.promises.open(`public/img/${name}`, 'r'); const readStream = fd.createReadStream(); const dynamicImport = new Function('specifier', 'return import(specifier)'); const mime = await dynamicImport('mime'); res.set({ 'Content-Type': mime.default.getType(fs.stat.name), }); return new StreamableFile(readStream); }
|
使用了mime获取文件类型,但从mime@4开始,只提供了esm导出,nest默认会输出为commonjs,所以需要import()动态引入。
但转为commonjs时,import()会被tsc转为reuqire(),解决问题:Dynamic import() with “module”: “commonjs”
download
Express提供了res.download()
方法,可以让浏览器下载文件。
sendFile告知浏览器它是一个流,但没让浏览器下载它。
而download在响应头里加上了 Content-Disposition: attachment; filename=”xxxx” 告知浏览器下载文件。
1 2 3 4
| @Get('download') download(@Query('name') name: string, @Res() res: Response) { res.download(`public/img/${name}`); }
|
压缩文件下载
compressing 压缩文件,StreamableFile 将压缩流返回。并设置 Content-Disposition 让浏览器下载文件。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import compressing from 'compressing'; @Get('zip') downloadZip( @Query('name') name: string, @Res({ passthrough: true }) res: Response, ) { const tarStream = new compressing.zip.Stream(); tarStream.addEntry(`public/img/${name}`); res.set({ 'Content-Disposition': `attachment; filename=${path.parse(name).name}.zip`, }); return new StreamableFile(tarStream); }
|
RxJS
RxJS(Reactive Extensions for JavaScript) 是一个使用可观察(Observable)序列来编写异步和基于事件(Event-based)程序的库。即观察者模式,编写异步队列和事件处理。cn文档v5
详见:学习笔记-RxJS
Nest 内置了 RxJs,如拦截器(Interceptor)等功能都是基于 RxJs 实现的。
Interceptor拦截器
Interceptor是具有@Injectable()
装饰器的类,实现了NestInterceptor
接口。可以拦截请求和响应,对它们进行转换和处理。
拦截器是面向切面编程AOP的一种实现,具有一系列有用的功能:
- 函数执行之前/之后绑定额外的逻辑
- 转换从函数返回的结果
- 转换从函数抛出的异常
- 扩展基本函数行为
- 根据所选条件完全重写函数 (例如, 缓存目的)
nest g itc <name>
创建拦截器
一个最简单的拦截器1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { Observable } from 'rxjs';
@Injectable() export class ConverterInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle(); } }
|
参考:
NestJS10-Interceptors
NestJs学习之旅(9)——拦截器
深入了解Nest拦截器
NestInterceptor
NestInterceptor
接口定义了一个intercept
方法,接收两个参数:
context
:执行上下文,具有一些获取执行信息的方法。
next
:CallHandler
类型,它的handle
方法返回一个Observable
。
1 2 3
| interface NestInterceptor<T = any, R = any> { intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>; }
|
context参数是ExecutionContext
类型,继承自ArgumentsHost
,具有一些获取执行信息的方法。
1 2 3 4 5 6 7 8 9 10 11 12
| interface ExecutionContext extends ArgumentsHost { getClass<T = any>(): Type<T>; getHandler(): Function; } interface ArgumentsHost { getArgs<T extends Array<any> = any[]>(): T; getArgByIndex<T = any>(index: number): T; switchToRpc(): RpcArgumentsHost; switchToHttp(): HttpArgumentsHost; switchToWs(): WsArgumentsHost; getType<TContext extends string = ContextType>(): TContext; }
|
next参数是CallHandler
类型,是对路由处理函数的抽象,它的handle()
方法就是调用路由处理函数,返回一个Observable
,包装了响应流,可以用Rxjs操作符对其进行处理。
1 2 3
| interface CallHandler<T = any> { handle(): Observable<T>; }
|
intercept方法返回Observable
,即拦截器的主要逻辑是对next.handle()
返回的Observable
进行处理,返回一个新的Observable
,而无需订阅它。
绑定拦截器
拦截器可以装饰控制器、路由方法,或是全局的。
@UseInterceptors()
在控制器或路由方法上使用拦截器,传入一个或多个拦截器。
在控制器上使用拦截器1 2 3 4
| import { ConverterInterceptor } from './converter/converter.interceptor'; @Controller() @UseInterceptors(ConverterInterceptor) export class AppController {}
|
在路由方法上使用拦截器1 2 3 4
| import { ConverterInterceptor } from './converter/converter.interceptor'; @Get('value') @UseInterceptors(ConverterInterceptor) getValue(){ return 'value'; }
|
app.useGlobalInterceptors()
配置全局拦截器,传入一个或多个拦截器,需要手动实例化。
全局拦截器1 2
| import { ConverterInterceptor } from './converter/converter.interceptor'; app.useGlobalInterceptors(new ConverterInterceptor());
|
格式化响应
使用Rxjs的操作符可以很方便地拦截加工响应流,实现格式化响应。
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Injectable() export class ConverterInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( map((data) => ({ data, code: 200, success: true, message: '请求成功', })), ); } }
|
记录处理时间
记录请求的处理时间,对于性能监控很有用。
1 2 3 4 5 6 7 8 9
| @Injectable() export class ConverterInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const now = Date.now(); return next.handle().pipe( tap(() => console.log(`After... ${Date.now() - now}ms`)), ); } }
|
超时与错误处理
可以设置请求的超时,并对超时等错误进行处理。
1 2 3 4 5 6 7 8 9
| @Injectable() export class ConverterInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( timeout(5000), catchError((err) => of('error')), ); } }
|
获取请求、响应对象
可以通过ExecutionContext
获取请求、响应对象,对它们进行处理。这都是在路由处理函数之前的操作。
尽管能拿到请求、响应对象,但不推荐在拦截器中进行鉴权,那是守卫的职责。拦截器的职责是对请求和响应进行处理。比如实现复杂的缓存拦截器,但实际上,nest已经提供了缓存API。
1 2 3 4 5 6 7 8 9 10 11 12
| @Injectable() export class ConverterInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const req = context.switchToHttp().getRequest<Request>(); const res = context.switchToHttp().getResponse<Response>(); console.log(req.url); res.set({ name: 'qx', }); return next.handle(); } }
|
请求链路日志记录
记录请求链路日志,对于排查问题很有用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { Logger } from '@nestjs/common'; import { format } from 'util';
@Injectable() export class ConverterInterceptor implements NestInterceptor { private readonly logger = new Logger(); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const start = Date.now(); return next.handle().pipe( tap((response) => { const request = context.switchToHttp().getRequest<Request>(); this.logger.log( format( '%s %s %dms %s', request.method, request.url, Date.now() - start, JSON.stringify(response), ), ); }), ); } }
|
结果1
| [Nest] 19404 - 2024/02/06 23:53:01 LOG GET /value 7ms [1,2,3]
|
执行上下文
在上面的拦截器中出现了执行上下文,这里详细介绍下。
Nest实质是一个IoC容器,本身不具备什么功能。是存粹的以一种程序架构理念实现的框架。
在服务低层,可以是Express或者Fastify的HTTP服务,也可以是微服务或者是WebSocket应用。无论Nest高层逻辑是什么样的,都与低层松耦合。当需要调取低层的某些功能时,就需要通过执行上下文(文档)来获取相关对象和信息。
例如守卫、过滤器和拦截器,都需要对整个请求-响应过程做出一定的干预和操作,最基本的就需要通过执行上下文来获取请求和响应对象。
ArgumentsHost
类提供了一些方法,用于获取执行上下文的信息。
1 2 3 4 5 6 7 8
| interface ArgumentsHost { getArgs<T extends Array<any> = any[]>(): T; getArgByIndex<T = any>(index: number): T; switchToRpc(): RpcArgumentsHost; switchToHttp(): HttpArgumentsHost; switchToWs(): WsArgumentsHost; getType<TContext extends string = ContextType>(): TContext; }
|
ExecutionContext
继承自 ArgumentsHost
,并提供了一些额外的方法,主要用于守卫和拦截器。
1 2 3 4
| interface ExecutionContext extends ArgumentsHost { getClass<T = any>(): Type<T>; getHandler(): Function; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export type ContextType = 'http' | 'ws' | 'rpc'; export interface HttpArgumentsHost { getRequest<T = any>(): T; getResponse<T = any>(): T; getNext<T = any>(): T; } export interface WsArgumentsHost { getData<T = any>(): T; getClient<T = any>(): T; } export interface RpcArgumentsHost { getData<T = any>(): T; getContext<T = any>(): T; }
|
执行上下文分为三种类型:http
、ws
、rpc
,分别对应HTTP、WebSocket和RPC(微服务)。
通过 getType()
获取当前执行上下文的类型,再使用 switchTo*()
获取对应的执行上下文对象。
1 2 3 4 5 6 7
| if (host.getType() === 'http') { } else if (host.getType() === 'rpc') { } else if (host.getType() === 'ws') { }
|
异常处理层
Nest内置异常处理层,应用程序没有处理的异常的会被这个层捕获,并且自动的返回一个对用户友好的响应。
异常处理层由 Exception filters
异常过滤器实现,该过滤器处理HttpException类型的异常(及其子类),当有异常被忽略(既不是HttpException也不是继承自HttpException的类),这个内置的异常过滤器会返回默认的JSON
1 2 3 4
| { "statusCode": 500, "message": "Internal server error" }
|
抛出标准异常
内置 HttpException
类,用于抛出标准的HTTP异常,并发送标准HTTP响应对象。
1 2 3 4 5 6 7 8
| class HttpException extends Error { constructor(response: string | Record<string, any>, status: number, options?: HttpExceptionOptions); cause: unknown; } interface HttpExceptionOptions { cause?: unknown; description?: string; }
|
其构造函数接收三个参数:
response
:定义响应体JSON内容,可以是字符串或对象。
status
:Http状态码,可以传入数字,或HttpStatus
枚举
options
:可选参数,包括cause
和description
属性,用于记录异常的原因和描述,不会添加到响应体中。
默认的情况下,JSON内容里面有2个属性:
statusCode
:默认为status
参数中提供的Http状态码
message
:Http错误的简短描述,即response
参数传入的字符串
要覆盖JSON响应体,需给response
参数传入对象。Nest将序列化该对象,并将其作为JSON响应体返回。
抛出HttpException标准错误1 2 3 4 5 6 7 8
| @Get('value') getValue() { throw new HttpException('Forbidden', HttpStatus.FORBIDDEN, { cause: new Error('Error'), description: 'Forbidden', }); return "123"; }
|
响应1 2 3 4
| { "statusCode": 403, "message": "Forbidden" }
|
注意:路由处理函数中抛出的异常,可以被拦截器catchError
操作符捕获,并处理,不会被异常处理层捕获。
内置 HTTP 异常
Nest提供了许多继承自HttpException
的异常类
如果这些异常不满足需求,可以自定义异常,创建一个继承自HttpException
的类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| BadRequestException UnauthorizedException NotFoundException ForbiddenException NotAcceptableException RequestTimeoutException ConflictException GoneException HttpVersionNotSupportedException PayloadTooLargeException UnsupportedMediaTypeException UnprocessableEntityException InternalServerErrorException NotImplementedException ImATeapotException MethodNotAllowedException BadGatewayException ServiceUnavailableException GatewayTimeoutException PreconditionFailedException
|
ForbiddenException1 2 3 4 5 6 7
| class ForbiddenException extends HttpException { constructor(objectOrError?: string | object | any, descriptionOrOptions?: string | HttpExceptionOptions); } interface HttpExceptionOptions { cause?: unknown; description?: string; }
|
这些内置异常类的构造函数接收两个参数:
objectOrError
:定义响应体JSON内容,可以是字符串或对象。和HttpException
的response
参数差不多。
descriptionOrOptions
:可选参数,包括cause
和description
属性,用于记录异常的原因和描述,其描述会添加到响应体的error字段上。
- 第二个参数也可以是一个字符串,直接表示
description
。
1 2 3 4 5 6 7 8
| @Get('value') getValue() { throw new ForbiddenException('Forbidden', { cause: new Error('Error'), description: '禁止访问', }); return "123"; }
|
1 2 3 4 5
| { "message": "Forbidden", "error": "禁止访问", "statusCode": 403 }
|
异常过滤器
内置的标准异常类已经能够应付大多数异常情况,但也有完全控制异常层的需求,例如在出现异常时记录日志、响应不同的JSON内容等。Exception filters
异常过滤器可以满足需求。
Exception filters
是具有@Catch()
装饰器的类,实现了ExceptionFilter
接口。它可以捕获指定类型或任何类型异常,不仅限于HttpException
。
@Catch()
不传入参数,表示捕获任何类型的异常。还可以传入若干异常类,表示捕获这些指定类型的异常。
创建异常过滤器:nest g f <name>
一个最简单的异常过滤器1 2 3 4 5
| import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; @Catch() export class ErrorFilter<T> implements ExceptionFilter { catch(exception: T, host: ArgumentsHost) {} }
|
ExceptionFilter
接口实现了catch
方法,接收两个参数:
exception
:捕获到的异常
host
:ArgumentsHost
类型,具有一些获取执行信息的方法。
1 2 3 4 5 6 7 8
| interface ArgumentsHost { getArgs<T extends Array<any> = any[]>(): T; getArgByIndex<T = any>(index: number): T; switchToRpc(): RpcArgumentsHost; switchToHttp(): HttpArgumentsHost; switchToWs(): WsArgumentsHost; getType<TContext extends string = ContextType>(): TContext; }
|
注意:一但绑定了异常过滤器,就会覆盖内置的异常过滤器,需要手动处理异常、返回响应。
绑定过滤器
过滤器可以装饰控制器、路由方法,或是全局的。
@UseFilters()
在控制器或路由方法上使用过滤器,传入一个或多个过滤器。
在控制器上使用过滤器1 2 3 4
| import { ErrorFilter } from './error/error.filter'; @Controller() @UseFilters(ErrorFilter) export class AppController {}
|
在路由方法上使用过滤器1 2 3 4
| import { ErrorFilter } from './error/error.filter'; @Get('value') @UseFilters(ErrorFilter) getValue() {}
|
app.useGlobalFilters()
配置全局过滤器,传入一个或多个过滤器,需要手动实例化。
全局过滤器1 2
| import { ErrorFilter } from './error/error.filter'; app.useGlobalFilters(new ErrorFilter());
|
简单的异常处理
绑定了异常过滤器后,就意味着需要手动处理异常,并返回响应。
一个最简单的,包含日志记录的异常过滤器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; import { format } from 'util';
@Catch(HttpException) export class ErrorFilter<T extends HttpException> implements ExceptionFilter { private readonly logger = new Logger(); catch(exception: T, host: ArgumentsHost) { const ctx = host.switchToHttp(); const req = ctx.getRequest<Request>(); const res = ctx.getResponse<Response>(); this.logger.error( format('%s %s %s', req.method, req.url, exception.getResponse()), ); res.status(exception.getStatus()).json(exception.getResponse()); } }
|
1
| [Nest] 30556 - 2024/02/07 17:03:49 ERROR GET /value { message: 'Forbidden', error: '禁止访问', statusCode: 403 }
|
pipe管道
pipe管道是用 @Injectable()
注释的类,实现了 PipeTransform
接口,具有 transform()
方法。
nest g pi <name>
创建管道
颗粒度:参数、方法、控制器和全局
作用:
- 转换:将输入数据转换为所需的形式,无法转换则抛出异常
- 验证:检查输入数据是否符合要求,否则抛出异常
过程:在控制器方法上插入管道,管道接收该方法的参数,并进行转换或验证,最后用转换后参数调用该方法(路由处理程序)。
注意:当无法转换或验证不通过时,管道会抛出异常,会被异常处理层捕获,之后将不会执行任何控制器方法。默认抛出 BadRequestException(400)
异常。
内置管道
Nest提供了一些内置的管道,用于转换和验证。
ValidationPipe
验证管道,基于 class-validator
和 class-transformer
ParseIntPipe
将输入转换为整数
ParseFloatPipe
将输入转换为浮点数
ParseBoolPipe
将输入转换为布尔值
ParseArrayPipe
将输入转换为数组
ParseUUIDPipe
将输入转换为UUID
ParseEnumPipe
将输入转换为枚举
DefaultValuePipe
设置默认值,当参数不存在时使用
ParseFilePipe
将上传的文件转换为文件对象
绑定转换管道
转换管道如内置的 Parse*Pipe
,作用于控制器方法的参数,将管道类传入参数装饰器。
1 2 3 4
| function Param( property: string, ...pipes: (Type<PipeTransform> | PipeTransform)[] ): ParameterDecorator;
|
使用 ParseIntPipe
将参数转换为整数。
1 2 3 4 5
| @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { console.log(typeof id, id); return this.loginService.findOne(id); }
|
无法转换时,抛出 BadRequestException(400)
异常。
1 2 3 4 5
| { "message": "Validation failed (numeric string is expected)", "error": "Bad Request", "statusCode": 400 }
|
可以手动实例化管道类,传入选项,自定义管道行为。
1 2 3 4 5 6 7 8
| interface ParseIntPipeOptions { errorHttpStatusCode?: ErrorHttpStatusCode; exceptionFactory?: (error: string) => any; optional?: boolean; } class ParseIntPipe implements PipeTransform<string> { constructor(options?: ParseIntPipeOptions); }
|
1 2 3 4 5 6
| @Param( 'id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_FOUND, }), )
|
自定义管道
管道实现了 PipeTransform
接口,具有 transform()
方法。
一个最简单的管道1 2 3 4 5 6 7 8 9 10 11
| import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
@Injectable() export class LoginPipe implements PipeTransform { transform(value: any, metadata: ArgumentMetadata) { console.log(value); console.log(metadata); return value; } }
|
transform()
方法接收两个参数:
value
:参数的值
metadata
:参数的元数据,包括type
、metatype
、data
等
其返回值将作为实际的值传入控制器方法的对应参数。
1 2 3 4 5 6
| interface ArgumentMetadata { readonly type: Paramtype; readonly metatype?: Type<any> | undefined; readonly data?: string | undefined; } type Paramtype = 'body' | 'query' | 'param' | 'custom';
|
实现 ParseIntPipe
的简单版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Injectable() export class LoginPipe implements PipeTransform { transform(value: any, metadata: ArgumentMetadata) { const isNumeric = ['string', 'number'].includes(typeof value) && !isNaN(parseFloat(value)) && isFinite(value as any); if (!isNumeric) { throw new BadRequestException( 'Validation failed (numeric string is expected)', ); } return parseInt(value, 10); } }
|
验证
DTO(Data Transfer Object) 数据传输对象,是一种以类的形式实际为数据的对象,用于前后端或程序间传输数据。
src\login\dto\create-login.dto.ts1 2 3 4
| export class CreateLoginDto { username: string; password: string; }
|
src\login\login.controller.ts1 2 3 4 5
| import { CreateLoginDto } from './dto/create-login.dto'; @Post() create(@Body() createLoginDto: CreateLoginDto) { return this.loginService.create(createLoginDto); }
|
这样对于数据结构的限制显然不够,且还需要在控制器中写验证逻辑。
类验证器
类验证器 class-validator和class-transformer两个库,用于验证和转换类的实例。
class-validator
提供了一些验证装饰器,用于验证数据。
1 2 3 4 5 6 7 8 9 10 11 12
| import { IsString, IsNotEmpty, Length } from 'class-validator'; export class CreateLoginDto { @IsNotEmpty() @IsString() @Length(3, 10, { message: '用户名长度必须为3到10位', }) username: string; @IsNotEmpty() @IsString() password: string; }
|
Nest内置的 ValidationPipe
验证管道,也是基于上面两个库实现的。
1 2 3 4 5
| @Post() create(@Body(ValidationPipe) createLoginDto: CreateLoginDto) { console.log(createLoginDto); return this.loginService.create(createLoginDto); }
|
当传入的数据不符合规则时,会抛出 BadRequestException(400)
异常。
1 2 3 4 5 6 7
| { "message": [ "用户名长度必须为3到10位" ], "error": "Bad Request", "statusCode": 400 }
|
自定义类验证管道
Nest提供了 ValidationPipe
验证管道,但也可以自定义验证管道,实现更复杂的验证逻辑。
ValidationPipe
也是通过 class-validator
和 class-transformer
实现的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { validate } from 'class-validator'; import { plainToInstance } from 'class-transformer'; @Injectable() export class LoginPipe implements PipeTransform { async transform(value: any, metadata: ArgumentMetadata) { const DTO = plainToInstance(metadata.metatype, value); const errors = await validate(DTO); if (errors.length) { throw new BadRequestException(errors, 'Validation failed'); } return value; } }
|
通过 plainToInstance()
实例化DTO类(转换参数对象为有类型的对象),并将 value
反射到DTO类上,再使用 validate()
验证DTO对象,返回错误类型数组,如果数组不为空,抛出异常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| { "message": [ { "target": { "username": "12", "password": "123" }, "value": "12", "property": "username", "children": [], "constraints": { "isLength": "用户名长度必须为3到10位" } } ], "error": "Validation failed", "statusCode": 400 }
|
自定义验证函数
registerDecorator()
用于注册自定义的验证装饰器,实现更复杂的验证逻辑。
实现 RequireOtherFields()
验证指定属性与另一属性的关联性。
例如 page
依赖于 size
实现分页,所以传入了 page
也必须同时传入 size
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { ValidationOptions, registerDecorator, ValidationArguments, } from 'class-validator';
function RequireOtherFields( field: string, validationOptions?: ValidationOptions, ) { return function (object: object, propertyName: string) { registerDecorator({ name: 'requireOtherFields', target: object.constructor, propertyName: propertyName, constraints: [field], options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { const relatedValue = args.object[field]; return ( (value !== undefined && relatedValue !== undefined) || value === undefined ); }, defaultMessage(args: ValidationArguments) { return `传入 ${args.property} 后也必须传入 ${args.constraints[0]}`; }, }, }); }; }
|
使用1 2 3 4 5
| export class FindAllDto { @RequireOtherFields('size') page?: number; size?: number; }
|
模式验证
有许多库实现了以模式来描述数据,如Joi、Zod等,并提供了验证数据的方法。下面以zod为例。
创建模式与DTO,与类验证器类似,装饰器与函数式的区别。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { z } from 'zod'; export const createLoginSchema = z .object({ username: z .string() .min(3, { message: '用户名长度至少为3位', }) .max(10, { message: '用户名长度最多为10位', }), password: z.string(), }) .required();
export type CreateLoginDto = z.infer<typeof createLoginSchema>;
|
创建模式验证管道
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { ZodSchema } from 'zod';
@Injectable() export class LoginPipe implements PipeTransform { constructor(private schema: ZodSchema) {} async transform(value: any, metadata: ArgumentMetadata) { try { const parsedValue = this.schema.parse(value); return parsedValue; } catch (error) { throw new BadRequestException(error, 'Validation failed'); } } }
|
绑定验证管道
1 2 3 4 5 6 7 8 9
| import { createLoginSchema, CreateLoginDto } from './dto/create-login.dto'; import { LoginPipe } from './login.pipe';
@Post() @UsePipes(new LoginPipe(createLoginSchema)) create(@Body() createLoginDto: CreateLoginDto) { console.log(createLoginDto); return this.loginService.create(createLoginDto); }
|
验证失败返回的json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "issues": [ { "code": "too_small", "minimum": 3, "type": "string", "inclusive": true, "exact": false, "message": "用户名长度至少为3位", "path": [ "username" ] } ], "name": "ZodError" }
|
全局管道
通过 ValidationPipe
验证数据是非常常见的,可以将其设置为全局验证管道,不必在每个控制器上单独使用。
1 2 3 4
| import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { app.useGlobalPipes(new ValidationPipe()); }
|
如果DTO的某些属性是可选的,应该启用 skipUndefinedProperties
,以跳过对未定义属性的验证。
1 2 3
| new ValidationPipe({ skipUndefinedProperties: true, }),
|
多管道
同一层级的管道可以有多个,按顺序从左向右执行。
前一个管道的输出作为后一个管道的输入,最后的输出作为控制器方法对应参数的值。
使用 DefaultValuePipe
设置默认值,以防止接收到非预期的null或undefined。
1 2 3 4 5
| @Get() findAll(@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number) { console.log(page); return this.loginService.findAll(); }
|
Guards守卫
Guards守卫是用 @Injectable()
注释的类,实现了 CanActivate
接口,具有 canActivate()
方法。
nest g gu <name>
创建守卫
颗粒度:方法、控制器和全局
传统的web应用,检测用户登陆、鉴权等,通常在控制层或中间件中处理,但中间件的职责是不明确的,且获取不到执行上下文,不知道调用next后哪个路由将被执行。
作用:守卫专门用于鉴权、角色、访问控制等操作,可以获取执行上下文,决定是否放行请求(执行路由)。
顺序:守卫的调用在中间件之后,在任何管道和拦截器之前。
CanActivate
CanActivate
接口定义了 canActivate()
方法,该方法接收 ExecutionContext
执行上下文参数,返回boolean,表示是否放行请求(执行路由)。
当返回false时,请求将被阻止,并抛出ForbiddenException
,返回403。
1 2 3
| export interface CanActivate { canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>; }
|
一个最简单的守卫
1 2 3 4 5 6 7 8 9 10 11
| import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs';
@Injectable() export class RoleGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { return true; } }
|
由于单一职责的关系,路由守卫只能返回boolean来决定是否放行当前请求。虽然可以获取请求、响应对象,但不建议在守卫中修改它们,出现问题较难排查。对于一些必要的附加数据,可以在中间件中完成。
使用守卫
通过 @UseGuards()
注释路由方法或控制器,绑定若干个守卫。
1 2 3 4 5 6
| import { RoleGuard } from './role/role.guard'; @Get('testRole') @UseGuards(RoleGuard) getTestRole() { return 'testRole'; }
|
app.useGlobalGuards()
配置全局守卫,传入若干个守卫,需要手动实例化。
main.ts1 2
| import { RoleGuard } from './role/role.guard'; app.useGlobalGuards(new RoleGuard());
|
当存在多个守卫时,按顺序执行,任何一个守卫返回false,都会阻止请求。
元数据与角色控制RBAC
守卫通过路由方法上的元数据来实现基于角色的访问控制(RBAC)。
流程:前端回传token等信息,后端解析出token中的角色身份,对比路由方法上的元数据(允许的角色),决定是否放行请求。
@SetMetadata()
设置元数据
1
| const SetMetadata: <K = string, V = any>(metadataKey: K, metadataValue: V) => CustomDecorator<K>;
|
1 2 3 4 5 6 7
| @Get('testRole') @UseGuards(RoleGuard)
@SetMetadata<string, string[]>('roles', ['admin']) getTestRole() { return 'testRole'; }
|
Reflector
反射器,用于获取元数据
Reflector.get()
传入装饰器引用或元数据key和元数据目标上下文(装饰器目标)获取元数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { Reflector } from '@nestjs/core'; @Injectable() export class RoleGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const roles = this.reflector.get<string[]>('roles', context.getHandler()); if (!roles || !roles.length) { return true; } const request = context.switchToHttp().getRequest<Request>(); return roles.includes(request.query.user as string); } }
|
自定义元数据装饰器
为了更加方便,可以自定义一个装饰器,用于设置元数据。
1 2 3 4 5 6 7 8 9 10
| function Roles<T = string>(...roles: T[]) { return SetMetadata<string, T[]>('roles', roles); } @Get('testRole') @UseGuards(RoleGuard)
@Roles('admin') getTestRole() { return 'testRole'; }
|
nest g d <name>
创建装饰器,快速生成一个设置元数据的装饰器。本质是对原有的装饰器进行封装。
role.decorator.ts1 2
| import { SetMetadata } from '@nestjs/common'; export const Role = (...args: string[]) => SetMetadata('role', args);
|
最佳实践:将key值定义为常量并导出,以便在其他地方使用。
1 2 3 4 5 6
| import { SetMetadata } from '@nestjs/common'; export const RolesKey = 'roles'; export const Roles = (...args: string[]) => SetMetadata(RolesKey, args);
import { RolesKey } from './role.decorator'; const roles = this.reflector.get(RolesKey, context.getHandler());
|
SetMetadata
是低级的元数据设置方法,还可以使用 Reflector.createDecorator()
创建一个元数据装饰器。通过它创建的装饰器,可以作为获取元数据的key(装饰器的引用)。
1 2 3 4 5 6 7
| import { Reflector } from '@nestjs/core'; export const Roles = Reflector.createDecorator<string[]>();
@Roles(['admin'])
import { Roles } from './role.decorator'; const roles = this.reflector.get(Roles, context.getHandler());
|
多层次元数据
多层次设置元数据1 2 3 4 5 6 7 8 9 10
| @Roles('user') @Controller() export class AppController { @Get('testRole') @UseGuards(RoleGuard) @Roles('admin') getTestRole() { return 'testRole'; } }
|
由于可以在多个层次上设置元数据(控制器、方法),可能需要从多个上下文中提取元数据。
Reflector.getAll()
获取所有上下文中的元数据,返回二维数组,如 [ [ 'admin' ], [ 'user' ] ]
Reflector.getAllAndMerge()
获取所有上下文中的元数据,并合并,如 [ 'admin', 'user' ]
Reflector.getAllAndOverride()
获取上下文数组中首个的元数据,如 [ 'admin' ]
1 2 3 4 5 6
| const contexts = [context.getHandler(), context.getClass()]; console.log( this.reflector.getAll(RolesKey, contexts), this.reflector.getAllAndMerge(RolesKey, contexts), this.reflector.getAllAndOverride(RolesKey, contexts), );
|
自定义装饰器
文档。在守卫中,就自定义了一个装饰器,用于设置元数据。
nest g d <name>
创建装饰器。
组合装饰器
applyDecorators()
用于组合装饰器,传入若干个装饰器,返回组合后的新装饰器。以减少控制器代码中重复的装饰器组合。
1 2 3 4 5 6 7
| import { applyDecorators } from '@nestjs/common'; export function Auth(...roles: string[]) { return applyDecorators( SetMetadata('roles', roles), UseGuards(RoleGuard), ); }
|
自定义参数装饰器
在Node中,将自定义属性附加到请求对象是常见的做法,在路由方法中获取请求对象,是麻烦且非必要的,可以通过 createParamDecorator()
自定义参数装饰器,快速获取自定义属性。
1 2 3 4 5 6 7 8
| import { ExecutionContext, createParamDecorator } from '@nestjs/common'; export const ReqUser = createParamDecorator( (data: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return data ? request.user?.[data] : request.user; }, );
|
自定义的装饰器也可以与管道一起使用,但必须将 validateCustomDecorators 设为 true,ValidationPipe默认不验证用自定义装饰器注释的参数。
1
| @User(new ValidationPipe({ validateCustomDecorators: true }))
|