一、从一个“拦路虎”说起

很多同学在做了几年业务后,都会遇到一个类似的场景:你写了一个全局的鉴权中间件,挂在 NestJS 应用上,想着所有接口都先过一遍身份校验,这样安全。可没想到,登录接口和健康检查也被“关在门外”了。用户还没登录呢,你让他去哪拿 token?这种“无差别拦截”就是全局中间件的典型问题。

1.1 全局中间件为什么“一视同仁”

在 NestJS 里,全局中间件最常见的注册方式有两种:一种是直接在 main.ts 里调用 app.use(),另一种是在 AppModule 里通过 MiddlewareConsumerforRoutes('*') 把它绑到所有路由上。不管哪种方式,效果都一样:每个请求进来,都会先被这个中间件“盘问”一遍。

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthMiddleware } from './auth.middleware';

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

  // 全局注册一个鉴权中间件,所有请求都躲不掉
  app.use(AuthMiddleware);

  await app.listen(3000);
}
bootstrap();

你看,代码写得很爽,但问题也来了:/auth/login/health 这些接口根本不需要鉴权,可它们也被中间件拦住了。这就是“全局”的代价——它不知道哪些路由是自家的“亲戚”,哪些是“外人”。

1.2 中间件的执行流程

在 NestJS 中,一个请求的生命周期大致是:中间件 -> 守卫 -> 拦截器 -> 管道 -> 处理器。中间件最先碰到请求,但它此时还不知道这个请求将来会去哪个控制器方法。这就像你站在公司门口查工牌,但你的系统里没有“哪个员工去哪个办公室”的名单。你只能靠“看门牌号”来猜,而这个门牌号就是 URL。

所以,要想让全局中间件放过某些路由,思路无非两种:一是“看路牌”放行,也就是条件匹配;二是“看内部通行证”,也就是装饰器排除机制。下面我们一个个说。

二、最直接的“条件匹配”方案

2.1 在中间件里判断请求路径

最简单粗暴的方式,就是在中间件内部写一堆 if 判断,当请求路径命中某个列表时,直接 next() 放行。这就好比门口保安认脸,是熟人就不拦。

// auth.middleware.ts
// 技术栈:NestJS + TypeScript
import { Request, Response, NextFunction } from 'express';

export function AuthMiddleware(req: Request, res: Response, next: NextFunction) {
  // 这些路径不需要登录,直接放行
  const publicPaths = ['/auth/login', '/auth/register', '/health'];

  // req.path 不包含查询字符串,比 originalUrl 更干净
  if (publicPaths.includes(req.path)) {
    return next(); // 熟人!快请进
  }

  // 剩下的一律要校验 token
  const token = req.headers['authorization'];

  if (!token) {
    return res.status(401).json({ message: '请先登录' });
  }

  // 这里假装校验 token,实际项目里要从 token 里解析用户信息
  return next();
}

这种写法优点很直白:你一眼就能看出放行了哪些路径。缺点也明显:每加一个公开接口,你都得回来改这个数组;而且万一某个路径写错了,接口就废了。对于路由一多、团队一大的项目,这活儿特别费劲。

2.2 用正则让匹配更有弹性

如果公开路径很多,而且有规律可循,比如都是以 /public/ 开头,那我们可以用正则表达式来匹配,省得一个个写。

// auth.middleware.ts
// 技术栈:NestJS + TypeScript
import { Request, Response, NextFunction } from 'express';

export function AuthMiddleware(req: Request, res: Response, next: NextFunction) {
  // 匹配 /public/ 开头的所有路径,比如 /public/docs、/public/css
  if (/^\/public\//.test(req.path)) {
    return next();
  }

  // 再匹配具体的登录、注册接口
  if (req.path === '/auth/login' || req.path === '/auth/register') {
    return next();
  }

  const token = req.headers['authorization'];
  if (!token) {
    return res.status(401).json({ message: '请先登录' });
  }

  return next();
}

正则虽然灵活了,但要小心“误伤”。比如 /public2 也会被上面的正则放过。所以正则写得太野,容易把不该放行的接口放出去,安全问题可不小。

三、NestJS 官方提供的“条件匹配”:exclude()

3.1 认识 MiddlewareConsumer 的 exclude 方法

其实 NestJS 官方早就想到了这个痛点,在 MiddlewareConsumer 里内置了一个 exclude() 方法。你可以在绑定全局中间件的时候,顺便把某些路由“踢出去”。这是一个非常优雅的条件匹配方案。

// app.module.ts
// 技术栈:NestJS + TypeScript
import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { AuthMiddleware } from './auth.middleware';

@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .exclude(
        // 精确排除登录接口,只针对 POST 请求
        { path: 'auth/login', method: RequestMethod.POST },

        // 字符串方式,所有方法都会被排除
        'health',

        // 支持通配符,排除所有 GET 请求的 public 目录
        { path: 'public/(.*)', method: RequestMethod.GET },
      )
      .forRoutes('*'); // 除上面排除的以外,全部应用中间件
  }
}

注意:这里 exclude 里的路径不能加前导斜杠,比如不要写成 /auth/login。而且它支持 path-to-regexp 那种通配符语法,(.*) 就表示任意内容。你可以把 exclude 想象成“黑名单的反向”,先把不想要的挑出来,剩下的才交给中间件。

3.2 exclude 的“能”与“不能”

exclude() 的好处是:不用在中间件内部写一堆 if,代码更干净,职责更单一。它坏处也很明显——它只能根据 URL 路径和 HTTP 方法来判断,不能根据业务条件来判断。

比如有这样一个需求:/order/list 接口在普通用户访问时要鉴权,但管理员访问时可以跳过。这种需求靠 exclude() 根本没法做,因为它不认识“谁是管理员”。这个时候,你就需要装饰器排除机制了。

四、装饰器排除机制:用“元数据”做精细过滤

4.1 为什么中间件拿不到路由的“小纸条”

在 NestJS 里,我们可以用 @Public() 这样的装饰器给某个接口打个标记,表示“这个接口不需要登录”。这种思路非常自然,就像给员工发一张“免检通行证”。可是问题来了:全局中间件是在路由匹配之前执行的,它根本不知道这个请求接下来会进入哪个控制器方法,自然也就看不到方法上的装饰器。

说白了,中间件是站在“路由器”前面的保安,它只知道你走哪个门,却看不到你办公室门上贴的“免检”标签。要拿到这个标签,我们得换一个岗位——守卫(Guard)。守卫是在路由匹配完成之后、处理器方法执行之前工作的,这个时候 NestJS 已经知道目标方法是谁了,它身上挂的所有装饰器元数据都能被读取到。

4.2 自定义一个 @Public() 装饰器

好消息是,NestJS 提供了一个叫 SetMetadata 的“标签打印机”,我们可以用它快速造出一个 @Public() 装饰器。

// public.decorator.ts
// 技术栈:NestJS + TypeScript
import { SetMetadata } from '@nestjs/common';

// 这个常量用来作为元数据的 key,建议放在单独文件里,避免到处打魔法字符串
export const IS_PUBLIC_KEY = 'isPublic';

// @Public() 就是一个“贴标签”的动作,把 true 贴到目标方法上
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

4.3 在控制器里用上“免检通行证”

有了这个装饰器,你就可以在任意接口上轻轻一点,告诉路由器:“这个接口不用检查 token”。

// auth.controller.ts
// 技术栈:NestJS + TypeScript
import { Controller, Post, Body, Get } from '@nestjs/common';
import { Public } from './public.decorator';

@Controller('auth')
export class AuthController {
  // 登录接口对外公开,不需要 token
  @Public()
  @Post('login')
  login(@Body() body: any) {
    return { message: '登录成功' };
  }

  // 获取用户信息需要 token,不贴 @Public()
  @Get('profile')
  profile() {
    return { message: '你的个人信息' };
  }
}

这样,POST /auth/login 就被贴上了“免检”标签。真正干活的人需要去读这个标签。

4.4 用全局守卫读取装饰器并实现“跳过”

现在我们创建一个 AuthGuard,在它里面检查当前处理器有没有 @Public() 元数据。如果有,直接放行;如果没有,就校验 token。

// auth.guard.ts
// 技术栈:NestJS + TypeScript
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class AuthGuard implements CanActivate {
  // Reflector 是 NestJS 提供的“照妖镜”,能看到元数据
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // 同时看一下方法上和类上有没有 @Public() 标记
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    // 如果有,直接放行,后续的登录校验全部跳过
    if (isPublic) {
      return true;
    }

    // 没有 @Public(),那就要老老实实查 token 了
    const request = context.switchToHttp().getRequest();
    const token = request.headers['authorization'];

    if (!token) {
      return false; // 守卫返回 false,请求被拒绝
    }

    // 这里可以做真正的 token 验证,例如解析 JWT
    // 验证通过后,把用户信息挂到 request 上,方便后面接口用
    (request as any).user = { id: 1, name: '张三' };
    return true;
  }
}

有了守卫,还需要把它全局挂上去。NestJS 提供了一个 APP_GUARD 的 provider,用起来特别方便。

// app.module.ts
// 技术栈:NestJS + TypeScript
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';

@Module({
  providers: [
    {
      provide: APP_GUARD,      // 使用 NestJS 内置的全局守卫 token
      useClass: AuthGuard,     // 你的自定义守卫
    },
  ],
})
export class AppModule {}

从此以后,所有接口默认都要走 AuthGuard 的检查,但只要接口上贴了 @Public(),就能顺畅通过。你会发现,和中间件相比,守卫与装饰器配合起来才是真正的“精细化过滤”。

4.5 想在中间件里也用装饰器?可以曲线救国

有人可能会问:我偏要在中间件里用装饰器排除,行不行?技术上可以绕个弯子:写一个工具服务,在应用启动时扫描所有控制器,找出带 @Public() 的路径,然后动态拼出一个排除列表,再传给 MiddlewareConsumer.exclude()。但这种做法要依赖 NestJS 的内部 API,扫描和解析路径都很繁琐,而且 exclude() 本身也只能处理路径,没法做更复杂的业务判断。有那功夫,直接上守卫不香吗?

所以在实际项目中,我强烈建议:全局中间件负责与具体业务无关的通用逻辑(比如日志、静态资源、跨域),而鉴权这类需要按接口精细控制的操作,一律交给守卫 + 装饰器

五、应用场景与方案取舍

5.1 全局中间件适合的场景

你如果只是想打印请求日志、给响应加个时间戳、做简单的请求体大小限制,这些和“具体接口是谁”没有关系的活,放在全局中间件里非常合适。哪怕不想记录某些接口,用 exclude() 排除掉路径即可。

5.2 守卫 + 装饰器适合的场景

一旦你的放行逻辑和业务挂钩,那就得用守卫。比如:

  • 某些接口只要登录就能访问,某些接口需要管理员权限;
  • 同一个接口,不同角色能看到的数据不同;
  • 你希望用 @Public()@Roles('admin') 这种声明式标签来管理权限。

这些需求,中间件做起来会非常别扭,而守卫配合 Reflector 和自定义装饰器,简直是天生一对。

5.3 注意事项

  • req.path 不包含查询字符串,req.originalUrl 会包含。如果要用 URL 条件匹配,记得选对。
  • exclude() 的路径不要带前导斜杠,并且支持通配符,但不支持正则表达式。
  • Reflector.getAllAndOverride 会同时检查控制器类和方法上的元数据,建议优先使用它,这样你可以把 @Public() 放在整个类上。
  • 自定义装饰器可以带参数,比如 @Public({ allowGuest: true }),然后在守卫里读取参数做更精细的判断。
  • 中间件一旦用 app.use() 注册,就无法通过 exclude() 排除;只有通过 MiddlewareConsumer 配置的中间件才支持 exclude()
  • 守卫返回 false 时,请求会直接返回 403。如果要自定义提示,最好在守卫里直接抛 UnauthorizedException
  • 使用全局守卫时,别忘了每个接口默认都是要鉴权的,这可能影响现有的一些纯内部接口,记得统一梳理。

六、总结

全局中间件“一视同仁”的脾气,确实给人添过不少堵。好在我们手里有牌可打:简单的路径放行,可以用条件匹配或官方 exclude();一旦涉及到按接口、按角色、按业务条件来跳过,装饰器加守卫才是王道。

遇到“全局中间件无法跳过某些路由”这种困境,别硬刚。先问自己:这个跳过逻辑是基于 URL 还是基于接口语义?如果是 URL,用 exclude() 最省心;如果是语义,那就果断换成守卫,用 @Public() 之类的装饰器给接口打上“免检”标签。这样代码既符合直觉,又方便后来人维护。

记住:工具没有高低之分,用对了地方,都是好工具。希望这篇文章能帮你少踩几个坑,让 NestJS 开发变得顺顺当当。