什么是 AOP 架构
在介绍 AOP 架构之前我们需要先了解一下 NestJS 对一个请求的处理过程。在NestJS中,一个请求首先会先经过控制器(Controller),然后 Controller 调用服务 (Service)中的方法,在 Service 中可能还会进行数据库的访问(Repository)等操作,最后返回结果。但是如果我们想在这个过程中加入一些通用逻辑,比如打印日志,权限控制等该如何做呢?这时候就需要用到 AOP(Aspect-Oriented Programming,面向切面编程)了,它允许开发者通过定义切面(Aspects)来对应用程序的各个部分添加横切关注点(Cross-Cutting Concerns)。横切关注点是那些不属于应用程序核心业务逻辑,但在整个应用程序中多处重复出现的功能或行为。这样可以让我们在不侵入业务逻辑的情况下来加入一些通用逻辑。
本篇文章将介绍 NestJS 中的五种实现 AOP 的方式(Middleware、Guard、Pipe、Interceptor、ExceptionFilter)
Middleware(中间件)
Middleware(中间件)这个大家应该不陌生,在 express 中经常会用到,Middleware 在 NestJS 中与 Express 类似,它是用于处理 HTTP 请求和响应的功能模块。中间件可以在请求进入控制器之前或响应返回给客户端之前执行一些操作。
我们先创建一个 Nest 项目来演示 Middleware 的用法
nest new test -p npm
然后再生成一个中间件
nest g mi test --no-spec --flat
此时我们的根目录多了一个test.middleware.ts
文件,因为我们的NestJS是基于Express的,所以可以将req和res的类型设置成Express中的类型
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
@Injectable()
export class TestMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
console.log('请求进入前');
next();
console.log('请求离开后');
}
}
其中next()就是执行下一个中间件,而next()后面的代码则是请求结束后才会执行的。那么我们如何使用这个中间件呢?
想要使用中间件需要到app.module.ts
进行注册
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TestMiddleware } from './test.middleware';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TestMiddleware).forRoutes('*');
}
}
我们需要实现一个NestModule
的类然后通过其中的configure方法进行使用。启动项目访问一下http://localhost:3000/
看一下控制台的打印
可以看到中间件生效了,这样我们就可以在这里加入一些通用的逻辑了,比如在请求之前进行request日志打印等。
如果我们想要这个中间件只对规定的路由生效该如何做呢?其实很简单,我们可以看到现在forRoutes种的参数是一个*
代表的是所有的路由都会生效,此时我们修改一下这个参数
consumer.apply(TestMiddleware).forRoutes('aaa');
然后app.controller.ts
种加一些路由
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('aa')
aa(): string {
return 'aa';
}
@Get('bb')
bb(): string {
return 'bb';
}
}
然后请求一下/aa
此时会发现中间件生效了,而请求/bb
则会发现并没有生效。不仅如此,我们还可以使用通配符*
指定一些路由,比如
consumer.apply(TestMiddleware).forRoutes('aaa*');
这样就会匹配到aaa
开头的路由,如aaa/bb
,aaacc/dd
等
中间件介绍完了,接下来我们看一下和它很像的拦截器Interceptor
Interceptor(拦截器)
Interceptor(拦截器)在NestJS中可以处理请求处理过程中的请求和响应,例如身份验证、日志记录、数据转换等,可以看到它和中间件类似,但其实也有很多的不同之处,这里后面会介绍。接下来我们看一下拦截器的使用方法
首先我们执行
nest g itc test --no-spec --flat
生成一个拦截器test.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class TestInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log(context.getClass());
console.log(context.getHandler());
return next.handle();
}
}
然后在main.ts中全局注册
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TestInterceptor } from './test.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TestInterceptor());
await app.listen(3000);
}
bootstrap();
在拦截器中context.getClass()
可以获取当前路由的类,context.getHandler()
可以获取到路由将要执行的方法,根据这个我们可以获取元数据MetaData
中的数据,例如我们新建一个自定义装饰器
nest g d test --no-spec --flat
然后生成了一个文件test.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Test = (...args: string[]) => SetMetadata('test', args);
这样我们就可以在Controller中通过@Test(“xxx”)注入元数据了,例如在app.controller.ts
中我们同时在类和aa
方法上注入元数据222
和111
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Test } from './test.decorator';
@Controller()
@Test('222')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Test('111')
@Get('aa')
aa(): string {
return 'aa';
}
@Get('bb')
bb(): string {
return 'bb';
}
}
然后我们就可以在拦截器中获取它们
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
@Injectable()
export class TestInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log(context.getHandler());
console.log(context.getClass());
const testClassData = this.reflector.get('test', context.getClass());
const testHandlerData = this.reflector.get('test', context.getHandler());
console.log(testClassData);
console.log(testHandlerData);
return next.handle();
}
}
注意这里还需要修改一下main.ts中全局注册的方式,需要传入Reflector
app.useGlobalInterceptors(new TestInterceptor(new Reflector()));
此时我们访问/aa
就可以获取到对应的元数据了
这样我们便可以通过这样元数据来进行一些自定义的操作了,而这些在中间件中则是无法做到的
除此之外我们可以看到拦截器返回的是一个Observable
类型的值,而Observable
类来自rxjs
,它是一个组织异步处理的库,提供了很多的操作符,可以简化我们异步逻辑的编写。例如,我们如果想要格式化一下返回的结果可以这样写
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Reflector } from '@nestjs/core';
@Injectable()
export class TestInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data: any) => {
return {
code: 200,
data,
};
}),
);
}
}
我们引入了它的映射map
方法,然后将返回的数据转换成我们想要的格式,这个在实际开发中经常用到,然后我们请求一下看看结果
可以发现请求结果被格式化了,这也是中间件无法做到的
除此之外,rxjs还提供了很多的操作符,如timeout
,tap
,catchError
等待,感兴趣的可以去试试
除了全局注册,拦截器还可以作用于单个handler,它的用法很简单,在app.controller.ts
引入TestInterceptor
,然后在需要的路由上通过UseInterceptors
启用即可
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { AppService } from './app.service';
import { TestInterceptor } from './test.interceptor';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@UseInterceptors(TestInterceptor)
@Get('aa')
aa(): string {
return 'aa';
}
@Get('bb')
bb(): string {
return 'bb';
}
}
这样拦截器便只有在/aa
路由上生效了
一般来说,使用元数据(MetaData)一般会在守卫(Guard)中,通过这些元数据来决定是否放行,那么接下来我们看一下守卫(Guard)的使用方法
Guard(守卫)
顾名思义,Guard可以根据某些自定义的条件在调用某个 Controller 之前返回true或false决定放不放行。
我们通过命令新建一个Guard
nest g gu test --no-spec --flat
然后就生成了一个test.guard.ts
文件
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class TestGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}
和拦截器一样有一个ExecutionContext类型的context参数,同样的我们可以根据context.getHandler()
拿到某个路由的元数据
const testHandlerData = this.reflector.get('test', context.getHandler());
Guard用法也有两种,分为全局路由守卫和控制器路由守卫,首先我们来看全局路由守卫的使用方法,用法和上面拦截器差不多,在main.ts中通过app.useGlobalGuards
进行注册
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TestInterceptor } from './test.interceptor';
import { TestGuard } from './test.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TestInterceptor(new Reflector()));
app.useGlobalGuards(new TestGuard());
await app.listen(3000);
}
bootstrap();
我们试着在Guard中返回一个false看下返回结果
可以发现请求被禁止了,一般情况下我们是通过获取当前路由的元数据以及判断token是否过期来决定是否放行。这里给大家看一下实际业务中的代码,大概了解一下即可
想要控制单个路由的守卫写法也和拦截器差不多,在app.controller.ts
使用UseGuards
启用即可
@UseGuards(TestGuard)
@Get('aa')
aa(): string {
return 'aa';
}
这样就只在/aa
路由中生效了
ExceptionFilter(异常过滤器)
在NestJS中有一个内置异常层可以自动处理整个程序中抛出的异常,比如你访问一个不存在的路由它会自动返回404
或者其它的Http异常如403,401,406等等,这些NestJS都会捕获到并返回给用户一些友好的响应,当无法识别异常时(既不是HttpException,也不是从HttpException继承的类)比如程序中代码的错误,用户会收到500的响应
另外,我们还可以通过HttpException类在程序中手动抛出Http异常也会被捕获,比如在app.controller.ts
抛出一个400异常
import { Controller, Get, HttpException } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
aa(): string {
throw new HttpException('这是一个400异常', 400);
return 'aa';
}
}
请求/aa
接口就会看到400异常的返回结果
注意,这里的状态码必须http状态码,我们可以从HttpStatus
枚举中获取
但是如果我们想改变这个返回的内容以及格式该怎么做呢?
这就要用到nest 的异常过滤器了,首先我们先生成一个异常过滤器
nest g f http-exception --no-spec --flat
并将这个过滤器http-exception.filter.ts
修改如下
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { HttpException } from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取 response 对象
const request = ctx.getRequest(); // 获取 request 对象
const status = exception.getStatus(); // 获取异常的状态码
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
如果想要全局使用,可以在main.ts
中使用app.useGlobalFilters
进行注册
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
或者在app.module注入
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './http-exception.filter';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}
这种方式适合在过滤器需要依赖注入的时候使用
如果只想在单个控制器中使用,可以使用@UseFilters
装饰器
@Get('aa')
@UseFilters(HttpExceptionFilter)
aa(): string {
throw new HttpException('这是一个400异常', 400);
return 'aa';
}
这里我们采用全局注册,此时我们在抛出HttpException
异常看下请求结果
可以看到已经是我们异常过滤器中response.status(status).json
中传入的数据及格式了
除了http异常状态码之外,通常在开发中还会返回一些业务的异常,对于这些异常的处理我们可以新建一个custom.exception.ts
,当然命名大家可以随意
import { HttpException, HttpStatus } from '@nestjs/common';
export enum CustomErrorCode {
USER_NOTEXIST = 10002, // 用户不存在
USER_EXIST = 10003, //用户已存在
}
export class CustomException extends HttpException {
private errorMessage: string;
private errorCode: CustomErrorCode;
constructor(
errorMessage: string,
errorCode: CustomErrorCode,
statusCode: HttpStatus = HttpStatus.OK,
) {
super(errorMessage, statusCode);
this.errorMessage = errorMessage;
this.errorCode = errorCode;
}
getErrorCode(): CustomErrorCode {
return this.errorCode;
}
getErrorMessage(): string {
return this.errorMessage;
}
}
这里实现了一个CustomException类继承HttpException类,接收三个参数:错误信息,业务异常码以及http错误码(默认为200,一般业务异常http请求通常是正常的)。同时提供了获取错误码及错误信息的函数。同时导出了一个自定义错误码的枚举
然后我们来到异常过滤器中http-exception.filter.ts
做一些逻辑处理
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { HttpException } from '@nestjs/common';
import { CustomException } from './custom.exception';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取 response 对象
const request = ctx.getRequest(); // 获取 request 对象
const status = exception.getStatus(); // 获取异常的状态码
//判断是否为自定义类
if (exception instanceof CustomException) {
response.status().json({
statusCode: exception.getErrorCode(),
message: exception.getErrorMessage(),
timestamp: new Date().toISOString(),
path: request.url,
});
return;
}
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
这里通过判断是否是通过自定义异常抛出的错误来返回不同的结果,然后我们在控制层抛出一个自定义错误看一下
import { Controller, Get, HttpException, UseFilters } from '@nestjs/common';
import { AppService } from './app.service';
import { CustomException, CustomErrorCode } from './custom.exception';
import { HttpExceptionFilter } from './http-exception.filter';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
@UseFilters(HttpExceptionFilter)
aa(): string {
throw new CustomException('姓名已存在', CustomErrorCode.USER_EXIST);
return 'aa';
}
}
然后访问/aa
接口会发现请求是成功的,并且statusCode变成了我们期望的10003
这样业务中便可以根据返回的statusCode做响应的处理了
Pipe(管道)
在NestJS中,Pipe是用来做参数转换的。NestJS提供了很多内置的Pipe,如
- ParseIntPipe: 用于将输入数据解析为整数。它可以将字符串形式的整数转换为JavaScript整数。如果无法解析为整数,它会抛出BadRequestException异常。
- ParseBoolPipe: 用于将输入数据解析为布尔值。它可以将字符串形式的"true"和"false"转换为对应的布尔值。如果无法解析为布尔值,它会抛出BadRequestException异常。
- ParseUUIDPipe: 用于将输入数据解析为UUID(Universally Unique Identifier)。它可以将字符串形式的UUID转换为UUID对象。如果无法解析为UUID,它会抛出BadRequestException异常。
- ValidationPipe: 用于验证请求数据,通常用于验证请求体数据、查询参数、路由参数等。它使用了类似于class-validator库的装饰器来进行验证。如果验证失败,它会抛出ValidationException异常。
- DefaultValuePipe: 用于为缺少的参数提供默认值。如果某个参数未传递,它会使用提供的默认值替代。
等等
这是一个使用ParseIntPipe的例子
import { Controller, Get, ParseIntPipe, Query } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
aa(@Query('age', ParseIntPipe) age: number): number {
console.log(age);
return age;
}
}
当访问/aa?age=18
的时候就会发现18被转成int类型了。我们再传一个无法解析为int的类型试一下,如/aa?age=ddd
可以看到抛出了一个400的错误,如果我们想自定义这个错误,可以这么做
@Get('aa')
aa(
@Query(
'age',
new ParseIntPipe({
errorHttpStatusCode: 401,
}),
)
age: number,
): number {
console.log(age);
return age;
}
这样它就会抛出401异常,不仅如此,这里还可以抛出一个异常给给异常处理器处理,我们用上面写好的自定义异常处理器试一下
import { Controller, Get, ParseIntPipe, Query } from '@nestjs/common';
import { AppService } from './app.service';
import { CustomException, CustomErrorCode } from './custom.exception';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
aa(
@Query(
'age',
new ParseIntPipe({
exceptionFactory(msg) {
throw new CustomException(msg, CustomErrorCode.USER_EXIST);
},
}),
)
age: number,
): number {
return age;
}
}
然后就会发现被自定义异常处理器处理了
除了这些内置的Pipe,NestJS还允许我们自定义Pipe,执行
nest g pi test --no-spec --flat
然后生成了test.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class TestPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
return value;
}
}
其中value就是接收的值,metadata是一个包含被处理数据的元数据对象,它有两个属性分别为
-
type: 表示正在处理的数据的类型。可以是 ‘body’、‘query’、‘param’ 或其他。这可以让我们确定管道是应用于请求体、查询参数、路由参数还是其他类型的数据。
-
metatype: 表示正在处理的数据的原始 JavaScript 类型。例如,如果正在处理一个参数,并且该参数是一个字符串,那么 metatype 可能是 String 类型。
那么如何使用自定义的Pipe呢?其实和上面的都差不多,第一种是全局使用,可以在main.ts使用app.useGlobalPipes()
进行全局注册,还可以在module中通过注入的方式使用
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { CustomPipe } from './custom.pipe';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: CustomPipe, // 启用自定义管道
},
],
})
export class AppModule {}
在app.controller.ts
中使用
import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { AppService } from './app.service';
import { TestPipe } from './test.pipe';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
aa(@Query('age', TestPipe) age: string) {
return age;
}
}
如果仅仅想在一个控制器中使用,可以通过@UsePipes
装饰器进行修饰
import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { AppService } from './app.service';
import { TestPipe } from './test.pipe';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('aa')
@UsePipes(TestPipe)
aa(@Query('age') age: string) {
return age;
}
}
总结
本篇文章介绍了NestJS的实现AOP的五种方式,合理的使用它们可以可以帮助我们更好地组织和管理代码,提高可维护性和可测试性,并使应用程序更加灵活和可扩展。这对于大型项目或需要处理多个横切关注点的应用来说尤其有用。