一、Exception Filter
(一)Intro
Nestjs 拥有一个内置的Exception Layer
,负责捕获并处理应用中所有未处理的异常。当你没有在项目代码中手动处理程序抛出的异常时,该异常会被Exception Layer
捕获并自动向客户端发送一个规范且可读性强的异常响应。
该操作由内置的全局异常过滤器执行,开箱即用,默认会处理所有类型为 HttpException
及其子类的异常,当异常无法识别时(既不是 HttpException 也不是继承自 HttpException 的类),内置异常过滤器会生成以下默认 JSON 响应
json
{
"statusCode": 500,
"message": "Internal server error"
}
(二)Throwing standard exceptions
Nestjs 提供了内置的 HttpException 类,在常见的基于 REST / GraphQL API 风格的应用中,它是在特定的错误条件出现时,向客户端返回标准的 HTTP 响应对象的最佳实践。
HttpException 构造函数需要传递两个参数来决定响应结果
response:定义响应体内容,可以是字符串,也可以是对象
status:定义 HTTP 状态码,必须是合法的 HTTP 状态码【使用 HttpStatus 类】
options:可选参数,可以用来传递异常发生的原因,该原因不会被序列化到结果中,但是它在打印日志来记录异常的详细信息时会非常有帮助
默认情况下,响应体中会包含两个属性:
statusCode:根据 status 参数生成的 HTTP 状态码
message:基于 response 生成的一个简短的描述信息
json
{
"statusCode": 403,
"message": "Forbidden"
}
如果希望只覆盖 JSON 响应体中的 message 部分,传递一个字符串到 response 参数中即可
json
{
"statusCode": 403,
"message": "This is a custom message"
}
如果需要重写完整的响应体对象,可以传递一个对象给到 response 参数,Nestjs 会自动序列化该对象并将其返回给客户端。
ts
@Get()
async findAll() {
try {
// 执行 Service 中用来读写数据库的函数
await this.service.findAll()
} catch (error) {
// 报错则抛出异常
throw new HttpException({
status: HttpStatus.FORBIDDEN,
error: 'This is a completely custom message',
}, HttpStatus.FORBIDDEN, {
cause: error
});
}
}
上述示例中,我们定义了一个 findAll 方法,当客户端发送请求并调用到该方法时,执行出错则抛出 Forbidden 异常。
json
{
"status": 403,
"error": 'This is a completely custom message'
}
(三)Exception Filters
虽然内置的异常过滤器可以自动处理很多应用场景,但是你在开发时可能会希望能够完全掌控异常层的行为,例如,我们在前后端对接过程中,通常都会约定一个统一的接口响应规范,每个公司的规范可能都不尽相同,此时就无法直接把所有工作全部推给 Nestjs 内置的异常层了。
为了满足这一需求,Nestjs 也提供了相应的解决方案 -- Exception Filters
它负责捕捉 HttpException
类实例的异常,并为它们实现自定义响应逻辑。为此,我们需要访问底层平台的 Request 和 Response 对象。从 Request 对象中提取原始 url 并将其包含在日志信息中,使用 Response 对象直接控制接口的响应内容。
ts
// 告诉 Nestjs 仅捕获类型为 HttpException 的异常
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
// 注入 LoggerService 以提供日志服务
constructor(private readonly logger: LoggerService) {}
// exception:当前处理的异常对象
// host:ArgumentsHost 类型的对象,我们可以从中获取到传递给路由函数【出现异常的位置】的 Request 和 Response 对象
catch(exception: HttpException, host: ArgumentsHost) {
console.log('error', exception)
// 切换到 Http 上下文
const ctx = host.switchToHttp()
// 拿到所有需要的对象
const response = ctx.getResponse()
const request = ctx.getRequest()
const status = exception.getStatus()
// 打印日志
this.logger.error(
null,
JSON.stringify({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url
})
)
// 根据项目组约定的规范返回响应结果
response.status(status).json({
Code: status,
Message: exception.getResponse(),
ReturnData: {
timestamp: new Date().toISOString(),
path: request.url
}
})
}
}
(四)Binding Filters
局部绑定:使用 @UseFilters
装饰器,并接收一个或多个 Exception Filter 类作为参数绑定到路由函数或控制器上。
ts
@Post()
// 可以仅传入类而非实例,将实例化的工作交给 Nestjs 执行
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException();
}
ts
@UseFilters(new HttpExceptionFilter())
export class CatsController {}
全局绑定:使用 app.useGlobalFilters
或 Provider注入
的形式将过滤器绑定到全局
ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
ts
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}
(五)Catch Everything
上述的 Exception Filter
仅针对 HTTP 异常做了捕获,但是实际开发中可能会有其他类型的异常,刚才定义的异常过滤器就无法处理了。
下述示例中,我们将使用 HttpAdapterHost,而不是特定平台的功能,比如 Express 的 Request 和 Response 对象来发送响应,藉此来忽视代码运行平台的影响。
ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
// In certain situations `httpAdapter` might not be available in the
// constructor method, thus we should resolve it here.
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
};
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
}
需要注意的是,如果你在项目中将捕获所有内容的异常过滤器 与绑定到特定类型的过滤器 组合在一起时,应该首先声明Catch anything过滤器,以允许特定的过滤器正确处理绑定的类型。
二、Pipes
Nestjs 的管道主要有两个应用场景:
-
转换【transformation】:将输入的内容转换为期望的格式
-
校验【validation】:判断输入的内容是否合法,合法则放行,非法则抛出异常
Nestjs 可以在路由处理器函数被调用之前插入 Pipes
Pipes 可以接收用于该路由处理函数的参数,并对其执行转换 或校验逻辑。
(一)Built-in Pipes
Nestjs 提供了很多开箱即用的内置管道,同时也支持开发者编写自定义管道。
ValidationPipe
ParseIntPipe
:保证参数会被转换为 JS 数字类型,如果转换失败,则会抛出异常ParseFloatPipe
ParseBoolPipe
ParseArrayPipe
ParseUUIDPipe
ParseEnumPipe
DefaultValuePipe
ParseFilePipe
(二)Binding Pipes
为了使用管道功能,我们需要将一个管道类绑定到合适的上下文中
ts
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.catsService.findOne(id);
}
上述示例中,我们将 ParseIntPipe
类绑定到路由处理函数 findOne
上,并在调用函数之前处理传入的参数 id
。那么,假设客户端请求指定路由触发了上面的路由处理函数:
dash
GET localhost:3000/abc
由于字符串 'abc' 并不能转换为合法的数字类型,Nestjs 会抛出下述异常信息,并阻止 findOne 函数的执行
ts
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}
在上述示例中,我们仅将 ParseIntPipe 类本身作为 @Param 参数传入,实例化的工作则依靠 Nestjs 的依赖注入机制全部交给框架实现。当然,和 Guards 一样,我们也可以手动实例化 ParseIntPipe 类来自定义其行为。
ts
@Get(':id')
async findOne(
@Param(
'id',
// 修改抛出异常时的状态码
new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE
}
)
)
id: number,
) {
return this.catsService.findOne(id);
}
(三)Custom Pipes
每个管道都必须实现 transform()
方法来实现 PipeTransform
接口,基本结构如下:
ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
return value;
}
}
transform 方法需要传递两个参数:
-
value:当前处理的方法参数(在被路由处理方法接收之前)
-
metadata:当前处理的方法参数的元数据,基本格式如下:
ts
export interface ArgumentMetadata {
// 指明参数用的哪个装饰器:@Body(), @Query(), @Param(), 或是自定义参数
type: 'body' | 'query' | 'param' | 'custom';
// 参数的元类型,例如 String。需要注意的是:如果你在路由处理函数的参数签名中省略了类型声明,或者使用普通JavaScript,该值为 undefined。
metatype?: Type<unknown>;
// 传递给装饰器的字符串,例如 @Body('string'),如果圆括号内未传值,则为 undefined
data?: string;
}
(四)Schame Based Validation
在实际项目场景中,我们可能会需要校验复杂结构的参数对象,以保证客户端发送的请求体是合法的,虽然我们可以将校验逻辑放到路由处理函数内执行,但这并不是个理想的解决方案,因为它会破坏单一责任原则。
Nestjs 提供了多个方法帮助我们实现这一需求。
方法一:Zod
第一步,安装 Zod 库
bash
npm install --save zod
第二步,使用 Zod 提供的方法创建 Schema
ts
import { z } from 'zod';
export const createCatSchema = z
.object({
name: z.string(),
age: z.number(),
breed: z.string(),
})
.required();
export type CreateCatDto = z.infer<typeof createCatSchema>;
第三步,使用 @UsePipes
绑定校验管道,并传入先前定义好的 Schema
ts
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
第四步,使用 this.schema.parse()
方法校验管道收到的 value 参数,报错则抛出异常
ts
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodObject } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodObject<any>) {}
transform(value: unknown, metadata: ArgumentMetadata) {
try {
this.schema.parse(value);
} catch (error) {
throw new BadRequestException('Validation failed');
}
return value;
}
}
方法二:Class Validator
第一步,安装 class-validator 和 class-transformer 库
dash
npm i --save class-validator class-transformer
第二步,使用 class-validator 暴露的装饰器来进行校验。这种方法的好处在于:CreateCatDto 类仍然是 Post 主体对象的唯一事实来源(不必创建单独的验证类)。
ts
import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
第三步,编写校验管道
ts
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
// 由于通过网络请求传递的参数是没有任何类型信息的
// class-validator 需要根据开发者在 DTO 中使用的装饰器,将接收的原始 JS 对象转换为类型化对象,以方便我们应用校验逻辑
const object = plainToInstance(metatype, value);
// 开始校验,并判断校验结果是否合法,非法则抛出异常
const errors = await validate(object);
if (errors.length > 0) {
throw new BadRequestException('Validation failed');
}
return value;
}
// 跳过原始 JS 对象的校验过程
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}