Nestjs AOP 编程之 Interceptor 的使用

349 阅读4分钟

前两篇系列文章:

  1. # Nestjs AOP 编程之中间件的使用
  2. # Nestjs AOP 编程之 Guard 的使用

本文主要介绍 Interceptor 的使用。

概念

Interceptor 是拦截器的意思,可以在路由处理程序执行之前和之后运行一段代码,以实现自定义的拦截功能。其实就是在 Controller 方法执行前后加入逻辑。

image.png

这么看,Interceptor 跟中间件其实很像,都可以在请求到达之前和之后进行一些逻辑操作。在一些功能场景,它们确实可以实现相同的效果,但还是有一些区别的,下面有单独章节讨论。

创建

在 Nestjs 项目中创建一个 Interceptor:

nest g interceptor time --no-spec --flat

会在 src 目录下生成一个 time.interceptor.ts 文件,文件内容如下:

// time.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class TimeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

自定义拦截器需要实现 NestInterceptor 接口,接口中有一个方法 intercept,注意该方法返回值的类型是 Observable<any>,它是 RxJS 中的一个类型,表示一个任意类型值的异步数据流。

RxJS 是什么?

RxJS 是一个用于处理异步数据流的库,它提供了观察者模式的实现,并且支持多种操作符来帮助开发者处理数据流。

Nestjs 的 Interceptor 底层中集成了 RxJS,可以用它来处理响应。上面代码的 intercept 方法中,next.handle()调用后就会返回一个 Observable 对象,代表了一个异步操作的结果,我们就可以使用 RxJS 提供的操作符来对这个结果执行一些处理。

下面来完善这个拦截器的功能,记录程序处理的耗时,这里只放 intercept 方法的代码:

  // time.interceptor.ts 部分代码
  
  import { Observable, tap } from 'rxjs'; // 注意这里多导入一个 tap 
  
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...', context.switchToHttp().getRequest().originalUrl);
    const startTime = Date.now();
    return next.handle().pipe(
      tap(() => {
        console.log(`After... Response time: ${Date.now() - startTime}ms`);
      }),
    );
  }

解释一下代码:

  • 首先在控制台打印 Before...,这里用于标记请求已到达拦截器,表示请求将被处理,同时打印当前请求路径,便于区分;
  • 缓存请求处理之前的时间戳 startTime,然后处理请求。调用 next.handle() 执行下一步处理,结束后会返回 Observable 对象;
  • 使用 pipe() 方法,可以将多个操作符连接起来,让它们按照顺序处理数据流,这里只有一个 tap 操作符,它可以执行一些副作用而不修改输入的 Observable。我们在回调函数中计算出处理程序花费的时间,并打印到控制台。

这里还是需要强调下,拦截器方法中必须要调用 next.handle(),这是请求能够走下去的关键。

这就实现了一个自定义拦截器,接下来就是使用它。

使用

整个 Controller 使用

拦截器可以应用在某一个 Controller 上,这样其下面的所有路由都会被拦截,比如要对用户模块的所有路由拦截:

image.png

现在分别请求 user/alluser/single,查看拦截器效果:

image.png

可见针对整个 Controller 的拦截生效了。

单个路由使用

可以局部应用到某一个路由上:

image.png

好了,现在就只拦截了路由 user/all,就不放结果截图了。

全局使用

全局使用有两种方式,第一种就是在 main.ts 中:

image.png

注意需要实例化 TimeInterceptor 这个类。此时应用的所有路由都会被这个拦截器拦截。

第二种方式是在 AppModule 中声明:

image.png

这种方式的优势就是可以依赖注入,具体区别可以参考 # Nestjs AOP 编程之 Guard 的使用 中的全局使用方式,这里就不重复说明了。

多个拦截器的使用

多个拦截器会按照注册顺序执行,响应会按照相反的顺序通过所有的拦截器。为了直观看到执行顺序,我们不用上面的拦截器举例,而是再新建两个拦截器 log1 和 log2:

nest g interceptor log1 --no-spec --flat
nest g interceptor log2 --no-spec --flat

不写复杂的内容,只加上打印:

// log1.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class Log1Interceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 打印 log1 开始
    console.log('Log1 Before...');
    return next.handle().pipe(
      tap(() => {
        // 打印 log1 结束
        console.log('Log1 After...');
      }),
    );
  }
}
// log2.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class Log2Interceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 打印 log2 开始
    console.log('Log2 Before...');
    return next.handle().pipe(
      tap(() => {
        // 打印 log2 结束
        console.log('Log2 After...');
      }),
    );
  }
}

使用这两个拦截器时,直接依次传入即可:

  • Controller 上或者单个路由使用 @UseInterceptors(Log1Interceptor, Log2Interceptor)

  • 全局使用 app.useGlobalInterceptors(new Log1Interceptor(), new Log2Interceptor()) 或者

    image.png

不管哪种方式,最终调用后的结果都是:

image.png

由此也可知,next.handle() 执行的可能是具体的路由方法,也可能是下一个拦截器。我们可以进一步验证这一点,在 log2 拦截器中改用 map 操作符,它可以返回一个新的 Observable,我们返回一条数据:

image.png

那么理论上在 log1 拦截器上就可以获取这条数据,在 tap 操作中接收一下:

image.png

此时再执行一下程序,得到的结果:

image.png

可见在 log1 结束后成功拿到了 log2 返回的数据,证明 log1 中的 next.handle() 确实是执行的 log2 拦截器,且能拿到返回的数据。

如果拦截器的下一步是路由处理器,那么还可以拿到路由返回的数据做数据转换之类的任务,这里不展开多讲了。

与中间件的区别

前面概念章节说到 Interceptor 与中间件相似,主要体现在它们都可以在路由处理器之前和之后加入代码逻辑,并且可以影响请求和响应。对于某些功能场景,它们是可以实现类似的效果的,比如我用中间件记录请求耗时:

// time.middleware.ts

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

@Injectable()
export class TimeMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: () => void) {
  
    const start = Date.now();
    res.on('finish', () => {
      const end = Date.now();
      const elapsed = end - start;
      console.log(`${req.method} ${req.originalUrl} took ${elapsed}ms`);
    });

    next();
  }
}

这个中间件也实现了记录请求时间,但是效果跟拦截器还是不一样的。这里是监听当前请求结束事件的,计算的时间是整个请求流程花费的时间。而上面拦截器中的时间,是下一步执行程序花费的时间,下一步执行的可能是路由处理器也可能是另一个拦截器,就比如上面多拦截器的案例,log1 中执行 next.handle() 其实是执行 log2 拦截器。

中间件中可以直接拿到请求和响应的对象,更适合处理与 HTTP 请求直接相关的任务,如身份验证、日志记录、错误处理等,这些也是整个应用中比较广泛使用的逻辑。

而拦截器中可以拿到当前执行上下文,能获取当前请求执行的 controller 和 handler:

image.png

如请求 user/all时,获取打印:

image.png

这样就可以针对具体的请求方法做一些处理了。

当然也是能获取到请求信息的,如请求方法和请求路径等:

// 获取当前请求的信息
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;

总结就是拦截器其实更适合处理业务逻辑层面的任务,如数据转换等,因为它可以拿到返回的数据。

总结

本文介绍了 Nestjs 中的拦截器 Interceptor,它可以在 Controller 方法前后加入代码逻辑,实现请求或响应的拦截功能。

拦截器有三种使用方法:

  • 作用于 Controller 上,对其下所有的路由进行拦截;
  • 作用于单个路由,只对当前路由拦截;
  • 全局拦截,有两种方式:第一种是在 main.ts 中使用 app.useGlobalInterceptors(),需要传入拦截器实例;第二种是在 AppModule中 providers 上全局声明。

拦截器可以多个一起使用,会按照传入的顺序执行,然后按照相反的顺序返回响应。

拦截器相对于中间件,更适合处理业务逻辑层面的任务,如数据转换;而中间件更适合整个应用中比较广泛使用的逻辑,如日志记录,身份验证等。