Nestjs AOP 编程之中间件的使用

674 阅读11分钟

本篇文章将介绍 Nestjs 中间件的详细使用方式,阅读本文需要有一些 Nestjs 的基础。中间件是 Nestjs 中实现 AOP 编程的一种方式,所以先简单介绍一下 AOP。

AOP 理解

AOP(Aspect-Oriented Programming),翻译过来就是面向切面编程,它是一种编程范式。可以提高模块化,通过将横切关注点(Cross-cutting Concerns),从业务逻辑中分离出来,达到提高代码的可重用性、可维护性以及减少代码重复的目的。

光这么说,可能还是难以 get 到 AOP 的实际作用。先解释一下什么是“横切关注点“,其实就是一些特定的功能,比如日志记录、权限验证、性能监控、异常处理等等。这些功能一般不是业务逻辑的核心部分,但却是通用的,我们需要用一种手段,将这些通用功能加入到整个程序调用链路中,而不是重复地在代码中多处实现。那么 AOP 就是这种手段。

以 Nestjs 举例,一般接口请求到达时,会经过 Controller(控制器)、Service(服务)及 Repository(数据库访问) 几个逻辑,现在想要做一些日志记录,为了不侵入业务逻辑,我们可以在 Controller 前后“切一刀”,填入日志记录的逻辑。大致如图所示:

image.png

图片仅供参考理解,Nestjs 并没有“切“这一功能,但接下来要介绍的中间件,就可以实现 AOP 功能。

中间件

概念

中间件(Middleware)是 Express 中的概念,Nestjs 底层默认使用的也是 Express,所以也能使用中间件,只是做了进一步的细化操作。

中间件本质上就是一个函数,它可以在请求到达最终处理程序之前和响应离开应用程序之前执行一些操作。这些操作可以包括验证、日志记录、错误处理、请求修改、响应修改等。每个中间件都有机会查看或修改传入的请求对象以及传出的响应对象。

最简单的实现

现在尝试写一个简单的中间件,实现两个功能,一个当请求到达时,打印当前请求路径和请求方法,另一个就是在响应头中添加自定义的 header。最简单的写法就是在 main.ts 中 use 一个函数,完整代码如下:

// main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Request, Response, NextFunction } from 'express';

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

  // 自定义中间件
  app.use(function (req: Request, res: Response, next: NextFunction) {
    console.log('---start---');
    // 记录请求信息
    console.log(`请求路径: ${req.path}`);
    console.log(`请求方法: ${req.method}`);
    // 设置响应头
    res.setHeader('X-Custom-Header', 'This is a custom header');
    // 执行下一个中间件或者路由处理函数
    next();
    console.log('---end---');
  });
  
  app.enableCors();
  await app.listen(3000);
}

bootstrap();

直接使用 app.use() 注册函数中间件,其实是 Express 底层提供的方法,利用 Express 的能力来添加中间件到请求处理链中。此时中间件应用于全局,即对所有进入的应用程序请求生效,但不支持依赖注入。因此,如果你的中间件需要依赖服务,则不能直接使用此方法。

再来看函数,有三个参数分别是请求对象(req)、响应对象(res)和下一个回调函数(next)的,注意他们的类型是来自 Express。拿到 reqres,我们就可以针对请求和响应做很多功能,如示例中记录打印和设置响应头。而 next 函数是必须要调用的,只有调用了它才能确保请求可以继续在中间件链中流动,直至达到最终的路由处理函数或响应客户端,如果不调用 next(),请求将会被挂起,不会进一步处理。

说了这么多,我们来看下调用效果。随便写一个 /a 的请求接口,并在接口方法中加入console.log('接口 a 被执行了');,调用接口后看下终端打印:

image.png

可以看到 req 的信息已经正确打印了,同时也能看出是在调用 controller 方法前后执行的中间件逻辑。再去浏览器验证一下响应头是否被添加:

image.png

可见响应头也被成功添加了。我们不妨再多调两个接口看下,分别请求 /a/b/c,打印如下:

image.png

由此可确认,中间件是对每一次的请求都单独处理一遍,调用 next 后,依次传递处理。

创建中间件类

上面介绍的函数中间件使用方式虽然简单,但是开发中一般不会直接这样写,原因也说了,它不支持依赖注入。通常我们会创建一个中间件的类,然后在模块中使用。我们需要创建一个中间件类,Nestjs 提供了命令生成:

 // 名称是 log,--no-spec 不生成测试文件,--flat 不生成目录
 nest g middleware log --no-spec --flat

执行命令后,会在目录中自动生成一个中间件的类文件 log.middleware.ts,内容如下:

// log.middleware.ts

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

@Injectable()
export class LogMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    next();
  }
}

可以看到也有一个 use 方法,现在我们只需要将中间件的逻辑写在 use 方法中即可,将之前的中间件逻辑搬过来,代码如下:

// log.middleware.ts

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('---start---');
    // 记录请求信息
    console.log(`请求路径: ${req.path}`);
    console.log(`请求方法: ${req.method}`);
    // 设置响应头
    res.setHeader('X-Custom-Header', 'This is a custom header');
    // 执行下一个中间件或者路由处理函数
    next();
    console.log('---end---');

  }
}

然后在 app.module.ts 中启用这个中间件,方式是这样的:

// app.module.ts

// 部分代码
import { LogMiddleware } from './log.middleware';

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

现在来调用接口试试,记得把之前 main.ts 中的 app.use 方式注册的中间件去除。还是分别调用 /a/b/c,结果如下:

image.png

可以看到效果跟之前的一模一样。难道费劲半天只是实现了一样的效果吗?当然不是咯。

我们关注 forRoutes 这个方法,上面例子中我传入了 *,一般这种都是通配符,在这里表示针对所有路由生效。聪明的你可能想到了使用通配符来匹配生效的路由,比如 a* 这样的,没错,确实是可以这样写的,会匹配所有 a 开头的路由路径。接下来会详细介绍几种匹配方式。

forRoutes 匹配方式

匹配所有路由

这种上面已经说过了,传入字符 *。这里只做目录归类。

匹配特定控制器

什么意思呢?就是只匹配某一个 Controller 下的所有路由。比如我只想将中间件应用在 UserController 下的路由,那么就可以直接传入这个 UserController,修改 app.module.ts 中的逻辑:

// app.module.ts

// UserController 是我提前建好的,这里只展示核心代码
import { UserController } from './user/user.controller';

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    // 传入 UserController
    consumer.apply(LogMiddleware).forRoutes(UserController);
  }
}

现在来验证下效果,我在 UserController 中添加两个路由:

image.png

分别访问这两个路由,终端打印结果:

image.png

可以看到中间件生效了,响应头也是正常添加的:

image.png

image.png

再来请求一下 /a/b/c 这些路径,看看效果:

image.png

显然,再请求 UserControll 之外的路由时,中间件不会再生效了。

匹配路径

匹配路径,可以传入一段路径,可以是完整的请求路径,也可以是路径前缀。比如传入 user 时,就可以匹配所有 user开头的路径:

// app.module.ts

// 部分代码
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware).forRoutes('user');
  }
}

此时访问 user/test1user/test2 都可以应用到中间件,结果:

image.png

注意请求的路径变成了 /test1/test2,因为 user 现在是前缀。再强调一下前缀匹配的含义,就是 api/user/test1 这样的路径是不匹配的,因为它是部分匹配。

除了前缀匹配,你也可以直接传入完成的路径匹配,比如 user/test1

// app.module.ts

// 部分代码
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware).forRoutes('user/test1');
  }
}

其实也算是前缀匹配,只不过这个前缀是完整的路径。所以此时只有 user/test1 的请求能被应用到,为了看到效果,我们将中间件中的 req.path信息改为 req.originalUrl,打印原始请求路径。结果:

image.png

而此时请求 user/test2 是匹配不到的。那么问题来了,请求 user/test1/aaa 会匹配到吗?答案是可以的。因为 user/test1 是完整路径,也可以是前缀。

另外路径也是支持通配符的,比如 user* 这样的可以匹配所有 user 开头的路径。这里就不做举例说明了。

匹配路径+请求方法

可以同时匹配路径和请求的方法,这样写:

// app.module.ts

// 部分代码

// 引入 RequestMethod
import { equestMethod }  from '@nestjs/common';

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LogMiddleware)
      .forRoutes({ path: 'user*', method: RequestMethod.POST }); // 这里的 path 需要加通配符
  }
}

此时表示只匹配 user 开头的 POST 方法的路由。在 UserController 中添加一个 Post 路由,现在有两个 Get 路由,一个 Post 路由:

image.png

我们先请求一下 user/add 来试试效果,结果:

image.png

再来调用 /user/test1user/test2,会发现应用不到中间件。

你应该发现了,我这里的 path 加上了通配符 *,这里需要注意,如果直接写 user,会认为是完整路径 user,会匹配不到。

组合匹配

可以将上面的三种组合起来匹配,比如:

// app.module.ts

// 部分代码
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware).forRoutes(AController, 'bbb', {
      path: 'user*',
      method: RequestMethod.POST,
    });
  }
}

表示匹配 AController 下所有的路由,同时匹配 bbb 开头的路径,同时匹配 user 开头且方法为 Post 的路由。一些测试结果:

image.png

至此,中间件的所有匹配方式都介绍完了,其实本质上只有路径匹配和控制器匹配,只是可以多种编写形式,包括可以各种组合匹配。

多中间件使用

consumer.apply 支持传入多个中间件,它们会按照传入的顺序依次执行。现在我再添加一个用于记录接口调用时间的中间件,使用命令:

nest g middleware time --no-spec --flat

然后写上中间件逻辑,完整代码:

// time.middleware.ts

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

@Injectable()
export class TimeMiddleware implements NestMiddleware {
  use(req: any, res: any, 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();
  }
}

现在应用它,直接将它添加到 LogMiddleware 后面:

//app.module.ts 

// 部分代码
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware, TimeMiddleware).forRoutes('*');
  }
}

因为是对所有路由生效,所以访问任意路由后,查看打印结果:

image.png

可以看到两个中间件按照传入顺序执行了。

这时候再将它们的顺序调换下,改成consumer.apply(TimeMiddleware, LogMiddleware),然后再验证结果,会惊讶地发现还是按照上图的顺序执行的,并没有按照传入的顺序执行。其实原因很简单,因为我这个记录时间的中间件会等待当前接口执行结束才打印,所以即使它先触发,但是监听了 finish 事件,打印过程是一个异步的操作。如果我们中间件逻辑改成同步的,这样:

// time.middleware.ts

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

@Injectable()
export class TimeMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    
    onsole.log('时间中间件');

    next();
  }
}

此时再验证下结果:

image.png

可以看到时间中间件先执行了结果。以上就是多中间件的使用过程,你还可以传入更多的中间件,来完成自己想要的结果。

总结

本文首先介绍了 AOP 编程的概念,它是一种编程范式,通过分离横切关注点(如日志记录、权限验证、性能监控、异常处理等)来提高代码的模块化、可重用性和可维护性。

接下来详细介绍了 Nestjs 中间件的使用,它符合 AOP 编程的思想。中间件的创建方式主要有两种:

  • main.ts 中使用 app.use 创建一个函数中间件,作用全局,但不支持依赖注入;
  • 使用命令创建中间件类,在模块中调用,可以设置应用范围,更灵活,且支持依赖注入。

最后介绍了多个中间件的使用,它们会按照传入的顺序依次执行,但如果有需要等待请求结束后才执行的逻辑,会在最后执行。

本文是我在学习 Nestjs 过程中的总结,查阅了很多资料,且都通过代码验证。如果对你有用,请帮我点个赞,接下来我会继续写 GuardInterceptorPipeExceptionFilter 这几个 Nestjs 中实现 AOP 的方式。