一、从一个“拦路虎”说起
很多同学在做了几年业务后,都会遇到一个类似的场景:你写了一个全局的鉴权中间件,挂在 NestJS 应用上,想着所有接口都先过一遍身份校验,这样安全。可没想到,登录接口和健康检查也被“关在门外”了。用户还没登录呢,你让他去哪拿 token?这种“无差别拦截”就是全局中间件的典型问题。
1.1 全局中间件为什么“一视同仁”
在 NestJS 里,全局中间件最常见的注册方式有两种:一种是直接在 main.ts 里调用 app.use(),另一种是在 AppModule 里通过 MiddlewareConsumer 的 forRoutes('*') 把它绑到所有路由上。不管哪种方式,效果都一样:每个请求进来,都会先被这个中间件“盘问”一遍。
// 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 开发变得顺顺当当。
评论
围绕“NestJS全局中间件无法跳过某些路由?条件匹配与装饰器排除机制实现精细化过滤”参与讨论