Nestjs系列 Nestjs中的AOP架構

风希落發表於2024-03-04

什麼是 AOP

Springboot 中就存在 AOP 切面程式設計。在 Nest 中也同樣提供了該能力。
通常,一個請求過來,可能會經過 Controller(控制器)、Service(服務)、DataBase(資料庫訪問)的邏輯。
在這個流程中,若想要新增一些通用的邏輯,比如 日誌記錄、許可權控制、異常處理等作為一個通用的邏輯。
AOP 的目的就是將那些與業務無關,卻需要被業務所用的邏輯單獨封裝,以減少重複程式碼,減低模組之間耦合度,以方便專案的迭代與維護


Nest 中的 AOP 有五種:Middleware中介軟體、Guard守衛、Pipe管道、Interceptor攔截器、ExceptionFilter異常過濾器。

Middleware

Nest 中文網-中介軟體

全域性中介軟體

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use(function (req: Request, res: Response, next: NextFunction) {
    console.log('before-----', req.url);
    next();
    console.log('after-----');
  });

  await app.listen(3000);
}

當訪問 http://localhost:3000/person 時,則會觸發列印,同時,訪問其它請求地址同樣會觸發該中介軟體邏輯,這就是全域性中介軟體,可以在多個 handler 之間複用中介軟體的邏輯。

image

image

路由中介軟體

Nest 中除了全域性中介軟體,還有路由中介軟體。
建立一個路由中介軟體

# --no-spec 不生成測試檔案,--flat 平鋪,不生成目錄
nest g middleware log --no-spec --flat

在建立出的檔案中列印以下前後

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class LogMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
    console.log('before2', req.url);

    next();

    console.log('after2');
  }
}

然後在 AppModule 中啟用該中介軟體

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware).forRoutes('person*');
  }
}

image

其中 forRoutes 中就是指定該中介軟體在匹配到哪些路由時可以生效
image

路由中介軟體可以明確或者泛指定哪些路由可以使用該中介軟體,其餘是無法觸發該中介軟體的
全域性中介軟體則是所有的路由都會觸發該中介軟體,沒有任何約束

Guard

Guard 是路由守衛的意思,可以用於在呼叫某個 Controller 之前判斷許可權,返回 true 或者 false 來決定是否放行。

建立一個 Guard

nest g guard login --no-spec --flat

Guard 需要實現 CanActivate 介面,實現 canActivate 方法,可以從 context 拿到請求的資訊,然後做一些許可權驗證等處理之後返回 true 或者 false。

簡單使用

對生成的 guard 檔案內容返回 false,

@Injectable()
export class LoginGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 其中 context 是該此請求呼叫的執行上下文
  // 比如 context.getClass(), context.getHandler() 得到的列印就是 [class PersonController] [Function: findAll]
    console.log('guard log');

    return false;
  }
}

其中 context 引數是執行上下文,可以用來獲取此次呼叫的上下文資訊,比如context.getClass() context.getHandler(),訪問 /person,得到的列印就是如下圖所示內容,其中 context.getClass() 獲取當前路由的類,context.getHandler() 可以獲取到路由將要執行的方法

image

在某個請求(此處為 PerosnController 的 /person 路由)的 Controller 上新增 @UseGuards(LoginGuard)

image

然後訪問 http://localhost:3000/person ,請求報錯,並返回無許可權的提示資訊

image

就像這樣,Controller 本身不需要做啥修改,只需要加一個裝飾器,就加上了許可權判,這就是 AOP 架構的好處。

而且,就像 Middleware 支援全域性級別和路由級別一樣,Guard 也可以全域性啟用

全域性守衛

guard 的全域性守衛有兩種新增方式,可以在 main.ts 的 app 中新增,也可以在 AppModule 中新增,但是兩者之間在使用上是有一些不同。

main.ts 中新增全域性守衛

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 新增全域性守衛
  app.useGlobalGuards(new LoginGuard());

  await app.listen(3000);
}

此時由於 LoginGuard 中直接寫死返回 false,所以訪問任何一個路由都是 403 無權

AppModule 中新增全域性守衛

在 AppModule 中新增時,需要注意,全域性守衛是在 providers 中進行關聯,且其 proovide 提供的 token,必須是由 @nestjs/core 匯出的 APP_GUARD

import { APP_GUARD } from '@nestjs/core';
import { LoginGuard } from './login.guard';

@Module({
  imports: [PersonModule],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_GUARD,  // 注入的 token 必須是由 @nestjs/core 匯出的
      useClass: LoginGuard,
    },
  ],
})

兩種註冊方式的區別

main.ts 中,由 app 註冊的,是手動 new 的例項,是不在 IOC 容器中的,無法執行注入操作

@Injectable()
export class LoginGuard implements CanActivate {
  // 注入一個服務,呼叫其方法
  @Inject(PersonService)
  private readonly personService: PersonService;

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    console.log('guard log', this.personService.findAll());

    return false;
  }
}

在使用 app 註冊全域性守衛時,訪問任何一個路由都報錯,無法找到注入的服務模組。

image

而如果使用 AppModule 註冊全域性守衛,由於 Guard 是在 IOC 容器中,所以可以正常執行注入操作

image

當模組需要注入其它 provider 時,則需要在 AppModule 中進行註冊宣告

Interceptor

Interceptor(攔截器) 可以處理請求處理過程中的請求和響應,例如身份驗證、日誌記錄、資料轉換等,其作用和 Middleware 較為相似,但卻有明顯的不同之處。可參考文件 Nest 中文網-攔截器

新建一個 interceptor 模板:

nest g interceptor test --no-spec --flat
// test.interceptor.ts
@Injectable()
export class TestInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
  //  這裡的 context 和 Guard 的 context 差不多
    console.log(context.getClass(), context.getHandler());

    return next.handle();
  }
}

main.ts 中進行全域性註冊

import { TestInterceptor } from './test.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new TestInterceptor());
  await app.listen(3000);
}

攔截器的簡單應用

interceptor 與 middleware 其中不同的一點就是,interceptor 可以獲取到資料資訊,並對其進行操作,rjsx 中提供了多種功能,例如 map tap 等。
同時 interceptor 內部可以獲取到 context,和 guard 一樣,而 middleware 是不行的。

  • 對返回資料進行格式化包裝
export interface Response<T = any> {
  code: number;
  data: T;
}

@Injectable()
export class TestInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<Response> {
	// 返回的結果都會被格式化為 {code:xxx, data:...} 的格式
    return next.handle().pipe(map((data) => ({ code: 200, data })));
  }
}

image

攔截器的全域性註冊和區域性註冊

interceptor 的註冊方式和 guard 類似

  • 全域性註冊(方式一:app 註冊)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalInterceptors(new TestInterceptor());
  await app.listen(3000);
}
  • 全域性註冊(方式二:AppModule 註冊)
@Module({
  imports: [PersonModule],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_INTERCEPTOR,  // 該常量由 @nestjs/core 匯出
      useClass: TestInterceptor,
    },
  ],
})
  • 區域性註冊(在 Controller 新增裝飾器)
@Controller('person')
//  @UseInterceptors(LoginGuard)  // 當寫在這裡時,表明此攔截器將作用於該 Controller 下的所有 handler 內容
export class PersonController {
  constructor(private readonly personService: PersonService) {}

  @Get()
  @UseInterceptors(LoginGuard)  // 使用 @UseInterceptors 新增區域性攔截器
  findAll() {
    return this.personService.findAll();
  }
}

Pipe

管道的主要作用就是對引數進行校驗和轉換。Nest 中文網-管道

自定義管道

  • 建立 pipe 管道模板
nest g pipe validate --no-spec --flat
  • 對管道模板進行自定義的引數校驗
@Injectable()
export class ValidatePipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (Number.isNaN(parseInt(value))) {
      throw new BadRequestException(`引數${metadata.data}錯誤`);
    }

    return typeof value === 'number' ? value * 10 : parseInt(value) * 10;
  }
}

  • 建立一個請求
import { ValidatePipe } from 'src/validate.pipe';

@Controller('person')
export class PersonController {
  constructor(private readonly personService: PersonService) {}

  @Get('/add')  // @Query 接收第二個引數,即剛才的自定義校驗管道
  addNum(@Query('num', ValidatePipe) num: number) {
    return num + 1;
  }
}
  • 訪問瀏覽器地址 http://localhost:3000/person/add?num=amd

image

  • 訪問瀏覽器地址 http://localhost:3000/person/add?num=2

image

區域性管道和全域性管道

  • 全域性管道也是兩種註冊方式(app 和 AppModule)

image

  • 區域性管道

image

Nest 內建 Pipe

Nest 附帶了九個開箱即用的管道

  • ValidationPipe
    • ValidationPipe 較為特殊,詳細使用請檢視中文網文件:參考連結
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

簡單使用

  • ParseIntPipe 只允許整數 int 型別的數字透過校驗
import { ParseIntPipe } from '@nestjs/common';

@Get('/add')
addNum(@Query('num', ParseIntPipe) num: number) {
  return num + 1;
}

image

更多便捷引數驗證請檢視 Nest 中文網-管道#類驗證器

自定義內建管道的內容

當需要返回自定義的錯誤狀態碼時,可以例項化內建管道

@Get('/add')
addNum( @Query('num', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }) ) num: number ) {
  return num + 1;
}

image

ExceptionFilter

Nest 內建的異常層,所有未經處理的異常錯誤,該異常層就會進行捕獲,並返回對應的響應資訊,相當於內建的一個全域性異常攔截器。Nest 中文網-異常過濾器
比如手動 throw new BadRequestException(...) 就會被預設內建的異常層捕獲

nest g filter ex --no-spec --flat

對丟擲的 BadRequestException 進行單獨的異常過濾

import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter } from '@nestjs/common';
import { Request, Response } from 'express';  // Request 一定是從 express 中匯出,而不是 @nestjs/common

@Catch(BadRequestException)  // 捕獲 BadRequestException 異常
export class HttpFilter implements ExceptionFilter {
  catch(exception: BadRequestException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response.json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

區域性使用該異常過濾器

  @Get('/add')
  @UseFilters(HttpFilter)
  addNum(@Query('num', ValidatePipe) num: number) {
    return num + 1;
  }

image

http 相關的內建異常

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableException
  • InternalServerErrorException
  • NotImplementedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException

也可以自行擴充套件,自定義異常

export class MyException extends HttpException {
  constructor() {
    super('Custom Error', 999);
  }
}

全域性註冊和區域性註冊

  • 全域性異常過濾器的兩種註冊方式和管道、守衛等一致(app 和 AppModule)
// main.ts 中註冊
app.useGlobalFilters(new HttpFilter());

//  AppModule 中註冊
@Module({
  providers: [
    AppService,
    {
      provide: APP_FILTER,  // // 該常量由 @nestjs/core 匯出
      useClass: HttpFilter,
    },
  ],
})
  • 區域性註冊,可作用於單個 handler 指定異常,也可作用於整個 Controller 的 handler 指定異常
@Controller('person')
// @UseFilters(HttpFilter) 作用於該 controller 下的所有捕獲到的指定異常
export class PersonController {
  constructor(private readonly personService: PersonService) {}

  @Get('/add')
  @UseFilters(HttpFilter)  // 只作用於該 handler 的指定異常
  addNum(@Query('num', ValidatePipe) num: number) {
    return num + 1;
  }
}

AOP 的執行順序

該圖來自於 stackoverflow。原文地址

image

Middleware 是 Express 的概念,Nest 將其放在最外層執行。到了某個路由之後,會先呼叫 GuardGuard 用於判斷路由有沒有許可權訪問,然後會呼叫 Interceptor,對 Contoller 前後擴充套件一些邏輯,在到達目標 Controller 之前,還會呼叫 Pipe 來對引數做檢驗和轉換。在此期間,不論是 Pipe 校驗丟擲的異常或是其它所有的 HttpException 的異常都會被 ExceptionFilter 處理,最後返回不同的響應。

相關文章