第3章 Nest.js拦截器
3.1 拦截器介绍
Nest.js的拦截器和axios的拦截器是类似的,可以在网络请求处理的前后去执行额外的逻辑。拦截器从字面意思理解就是拦截,假设有流程A->B,拦截器要做的是A到B的过程中,将内容拦截下来处理后再丢给B,变成了A->拦截器->B。
在网络请求的逻辑中,拦截器的拦截位置如下:
- 客户端请求->拦截器(前置逻辑)->路由处理器->拦截器(后置逻辑)->客户端响应。
Nest.js拦截器效果如图3-1所示。
![]()
图3-1 Nest.js拦截器
Nest.js拦截器主要的用途有以下5点:
(1)统一响应格式:将返回数据包装成统一格式。
(2)日志记录:记录请求耗时、请求参数等信息。
(3)缓存处理:对响应数据进行缓存。
(4)异常映射:转换异常类型。
(5)数据转换:对响应数据进行序列化/转换。
在英语AI项目中,主要使用到第5点数据转换,因此我们主要学习这一点。
3.2 拦截器创建
如表1-2所示,可以通过nest g itc interceptor快速创建一个拦截器(interceptor可以替换为任何你想取的拦截器名称)。通过该命令会在src文件夹下创建interceptor文件夹,而interceptor文件夹下存放interceptor.interceptor.ts文件。
根据命令的生成规则,我们知道文件夹和文件的名称取决于我们命令对拦截器的名称,从而生成xxx文件夹和xxx.interceptor.ts文件。并且在这唯一的文件中,会提前生成好对应的Demo示例。
//src/interceptor/interceptor.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle();
}
}
拦截器有两种使用方式:
(1)局部使用。
(2)全局使用。
当想局部使用时,例如只想在src文件夹下的user模块使用,我们只需要注册到user模块中。那怎么注册?有3种注册方式,在user.module.ts、user.controller.ts以及user.controller.ts都可以注册,最主要的区别在于局部作用范围不同。Nest.js拦截器局部注册如表3-1所示。
表3-1 Nest.js拦截器局部注册
| 注册方式 | 作用范围 | 代码位置 | 优点 | 缺点 |
|---|---|---|---|---|
| 模块级别 | 整个模块所有控制器 | user.module.ts | 统一管理,自动应用到所有路由 | 无法灵活排除某些路由 |
| 控制器级别 | 单个控制器所有路由 | user.controller.ts | 控制器粒度控制 | 需在每个控制器添加装饰器 |
| 路由级别 | 单个路由方法 | user.controller.ts | 最精细的控制 | 代码重复,管理复杂 |
局部使用的具体代码不演示,可通过AI或者官方文档学习使用。
3.3 全局拦截器使用
在英语AI项目中会使用到全局使用,我们这里学习具体如何全局使用。步骤为以下2步:
(1)使用nest g itc <拦截器名称>快速创建一个拦截器。
(2)将拦截器注册到main.ts文件中,即在main.ts文件中导入刚创建的拦截器,并且使用Nest应用程序实例方法useGlobalInterceptors()。
// main.ts文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new InterceptorInterceptor());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
当然由于InterceptorInterceptor拦截器是一个类,所以我们需要使用new运算符创建拦截器的实例以供使用。到这里,InterceptorInterceptor拦截器就是全局使用,即每一个接口都会经过该拦截器。Nest.js全局注册的官方文档如图3-2所示。
![]()
图3-2 Nest.js拦截器全局注册
此时来编写InterceptorInterceptor拦截器内的逻辑,可见引入了来自rxjs的Observable类,rxjs是Nest.js内部自带的,主要用于处理流的,使用频率不高。通常获取数据需要区分同步与异步,同步直接获取,而异步通过Promise的then或者catch方法获取。如果此时有rxjs,就不需要我们去关注获取的数据是同步或者异步的问题,减少心智负担。rxjs会将这些数据统一转成一个数据流,然后通过管道(pipe)去接收,接收到之后可由我们处理该数据格式,无论是通过map遍历处理还是filter过滤等等,最终将处理好的数据格式返回就行。
以上是rxjs的核心理念,除此之外,它还可以同时处理多个异步,而then或者catch方法每次只能处理一个。
像InterceptorInterceptor拦截器中的所返回的next.handle()就是一个Observable(数据流),所以我们需要通过pipe(管道)去接收数据然后使用rxjs的map方法对数据处理之后再返回数据。
我们将原始数据包裹在一个标准响应结构中,添加了时间戳、请求路径、业务状态码、成功标志和自定义消息。这样确保了所有经过此拦截器的HTTP响应都遵循统一的JSON格式,包括 { timestamp, data, path, message, code, success } 等标准化字段,前端可以统一处理和错误追踪。
// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 将通用的执行上下文切换到HTTP特定的上下文
const ctx = context.switchToHttp();
// 获取当前HTTP请求的详细对象,包含了请求方法、URL、请求头、参数、主体等所有信息。
const request = ctx.getRequest<Request>();
return next.handle().pipe(map((data) => {
return {
timestmap: new Date().toISOString(),
data: data,
path: request.url,
message: 'success',//业务逻辑自定义
code: 200,//业务逻辑自定义
success: true,
};
}));
}
}
此时在浏览器的URL输入http://localhost:3000/user/123,访问带参数的get请求,get请求拦截效果如图3-3所示。在这里体现的是:路由处理器->拦截器(后置逻辑)->客户端响应。
![]()
图3-3 Nest.js全局拦截器-get请求拦截效果
message和code字段属于业务逻辑的部分,后续完成英语AI项目时,会根据业务实际逻辑去自定义设置。
3.4 优化全局拦截器
但此时全局拦截器还有一个很大的Bug,假如接口返回一个很大的数据,我们通过BigInt数据类型去处理返回,那么在通过全局拦截器时就会出现报错情况,全局拦截器处理BigInt类型报错如图3-4所示。
![]()
图3-4 全局拦截器处理BigInt类型报错
报错是error TS2322: Type 'bigint' is not assignable to type 'string'。即bigint类型无法赋值给string类型,这是很正常的。因为全局拦截器的这些参数都是通过JavaScript标准内置对象JSON.stringify()进行格式化的,而JSON.stringify()是没办法处理BigInt值的。在MDN文档中是这样表述这一异常情况:当尝试去转换 BigInt类型的值会抛出TypeError("BigInt value can't be serialized in JSON")(BigInt 值不能 JSON 序列化)。
// src/app.service.ts文件
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello() {
return BigInt(123456789123456789123456789)
}
}
所以我们需要针对BigInt类型的值去处理,通过编写transformBigInt方法去单独处理这一情况,主要处理的事情是当遇到BigInt类型的值就将它转成一个字符串。
const transformBigInt = (data: any) => {
if (typeof data === 'bigint') {
return data.toString();
}
return data;
};
此时将接口(get请求)返回给用户的data数据放入transformBigInt方法中即可。
// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
const transformBigInt = (data: any) => {
if (typeof data === 'bigint') {
return data.toString();
}
return data;
};
@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
return next.handle().pipe(map((data) => {
return {
timestmap: new Date().toISOString(),
data: transformBigInt(data),
path: request.url,
message: 'success',//业务逻辑自定义
code: 200,//业务逻辑自定义
success: true,
};
}));
}
}
但此时还会报错同样的问题(Type 'bigint' is not assignable to type 'string'),这是很正常的。我们来梳理下流程:
(1)接口返回数据给前端。
(2)全局拦截器拦截接口返回的数据进行处理。
(3)全局处理后的数据返回给前端。
我们已经在全局拦截器中处理好类型转换问题(BigInt转String),如果还有问题,就只能在第一步的接口返回数据给前端的步骤中。前端访问的是接口,而接口是体现形式是路由,路由层从业务层获取数据返回给前端。因此在业务层的数据是BigInt类型,则路由层所拿到的数据也会是BigInt类型。由于Nest.js是强制使用TypeScript的,所以我们需要到app.controller.ts文件中将get默认请求所返回的类型限制从string改成any类型或者string和bigint的联合类型。此时就能正常运行代码。
// src/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 | bigint {
return this.appService.getHello();
}
}
出于严谨的考虑,我们需要处理相应的边界判断,假如BigInt类型在数组里,在对象里呢?原有的处理方式就又解析不了了。
return [BigInt(123456789123456789123456789)];
return { a: BigInt(123456789123456789123456789) };
所以需要进一步强化transformBigInt方法,对数组遍历处理内部可能存在的BigInt类型,而对象则通过Object.entries()静态方法将对象切换成保存键值对的二维数组后,遍历键值对并针对其中的value值处理可能存在的BigInt类型,最后通过Object.fromEntries()静态方法将键值对形式的二维数组重新转换回原始对象。
-
对象打印效果:{ foo: "bar", baz: 42 }。
-
将可迭代对象切成二维数组:[ ['foo', 'bar'], ['baz', 42] ]。
将对象切成二维数组更方便找到键值对的值并进行遍历操作。
const transformBigInt = (data: any) => {
if (typeof data === 'bigint') {
return data.toString();
}
if(Array.isArray(data)){
return data.map(transformBigInt);
}
if(typeof data === 'object' && data !== null){
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}
return data;
};
做完以上的优化后,我们会发现接口要返回Date日期没办法正常返回给前端了,因为我们把对象全部都处理了,而JavaScript标准内置对象Date的使用是通过new运算符调用的实例对象,实例对象也是对象,也会被transformBigInt方法一并处理,所以在判断对象的内部逻辑中还需要判断是否是Date类型,若为Date类型则直接原路返回,不处理。
if(typeof data === 'object' && data !== null){
if(data instanceof Date){
return data
}
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}
完整的全局拦截器如下代码所示,后续英语AI项目中,会将该全局拦截器直接拿过去使用。
// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
//同步 异步 then catch ->数据流->pipe -> map filter -> 返回
const transformBigInt = (data: any) => {
if (typeof data === 'bigint') {
return data.toString();
}
if(Array.isArray(data)){
return data.map(transformBigInt);
}
if(typeof data === 'object' && data !== null){
if(data instanceof Date){
return data
}
return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}
return data;
};
@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
return next.handle().pipe(map((data) => {
return {
timestmap: new Date().toISOString(),
data: transformBigInt(data),
path: request.url,
message: 'success',//业务逻辑自定义
code: 200,//业务逻辑自定义
success: true,
};
}));
}
}
接下来对异常也格式化统一处理一下,逻辑思路与全局拦截器类似。当前端发起不符合规范和要求的网络请求,后端就会返回异常信息,方便前端去统一处理。
![]()
图3-5 异常情况的处理
此时我们需要总结nest命令的表1-2,找到filter命令来生成一个过滤器。命令是:nest g f <过滤器名称>,我们就通过nest g f exceptionFilter来生成一份过滤器吧。成功在src文件夹下创建exception-filter文件夹和exception-filter文件夹下的exception-filter.filter.ts文件,这些生成文件的命名规则都是一致的,不再赘述。
// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
@Catch()
export class ExceptionFilterFilter<T> implements ExceptionFilter {
catch(exception: T, host: ArgumentsHost) {}
}
通过以上exception-filter.filter.ts文件的代码,我们发现异常处理@Catch()装饰器是空的,空的表示处理所有的异常操作,包括非HTTP请求都会处理,但我希望这个业务只处理和HTTP相关的异常就可以了。所以我们需要从@nestjs/common中引入一个HttpException类,然后让@Catch()装饰器去继承HttpException类就可以了。
// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
catch(exception: T, host: ArgumentsHost) {}
}
在这里我们可以看到这个很有意思的设计理念,通过Nest命令生成的内容,它希望我们都能用得上,这种思想和TypeScript所想表达的含义是一致的,只写用得上且必要的部分。因此在通过Nest CLI 在生成过滤器模板时,会会默认使用 @Catch()(不带任何参数),示例性地展示如何捕获所有异常。但它只是一个类模板,需要我们手动把它注册为全局过滤器,或者在控制器上使用。
只有当我们明确在@Catch()中指定具体的异常类型(如 @Catch(HttpException) 或 @Catch(WsException)),过滤器才会从“捕获所有异常”转变为“仅处理特定类型的异常”。如图3-6所示的官方文档也说明了不同协议层(HTTP 与 WebSocket)对应的异常类型不同,因此需要在 @Catch() 中明确指定对应的异常类型。
![]()
图3-6 HTTP异常过滤层的说明
接下来我们来对异常处理情况进行统一的格式化处理。这里的code(异常状态码)就不采用我们自定义的,而是使用exception内部定义的状态码,因为Nest内置的HttpException已经为所有常见错误定义了标准化的状态码(如 400、401、403、404、500 等),这些状态码符合 HTTP 协议本身的语义。直接使用exception.getStatus()可以确保服务端返回的错误信息在网络层面是可预测和通用的。Nest.js内置异常处理层说明如图3-7所示。
![]()
图3-7 Nest.js内置异常处理层说明
当token过期了,exception.getStatus()会自动识别并设置成401状态码,没有权限则403状态码。因此exception.getStatus()会自动化的根据实际情况去调整,非常方便。对应的详细讲解可阅读Nest.js的官方文档:Exception filters | NestJS - A progressive Node.js framework。
如果再自定义一套error code,就等于需要维护两套错误体系:HTTP 状态码 + 我们自己额外设计的业务错误码,这会造成重复劳动、文档负担加重以及维护难度上升。而直接使用 HttpException 内部的状态码可以保持异常捕获逻辑与框架一致,不需要额外重复造轮子。
// src/exception-filter/exception-filter.filter.ts文件
import { ArgumentsHost, Catch, ExceptionFilter,HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
catch(exception: T, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const request = ctx.getRequest<Request>()
const response = ctx.getResponse<Response>()
return response.status(exception.getStatus()).json({
timestamp: new Date().toISOString(),
path: request.url,
message: exception.message,
code: exception.getStatus(),
success: false,
})
}
}
最后,过滤器和拦截器一样,在main.ts文件中全局注册一下,则可以作用于整个项目的异常情况处理。
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
import { ExceptionFilterFilter } from './exception-filter/exception-filter.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new InterceptorInterceptor());
app.useGlobalFilters(new ExceptionFilterFilter());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
全局异常情况的过滤处理效果如图3-8所示。
![]()
图3-8 全局异常情况的过滤处理效果