在上一篇文章中分析了NestJS的拦截器的知识点之后,本文开始分析NestJS的守卫的知识点。
本文是一套系列文章,有很强的前后联系,如果您对NestJS感兴趣的话,建议您从本系列的开头开始阅读。
初探NestJS的运行原理之守卫
在上一节我们阐述拦截器的时候曾提到过,拦截器主要是在运行时提供AOP的支持,是对控制器的方法的增强或者削弱,但是拦截器不能中断请求。
但是在实际开发中,中断请求是一个多么常见的需求啊,比如鉴权操作,如果用户没有权限服务器就返回401,然后前端就知道给用户提供相应的引导。在上一篇文章中,我们聊过关于中间件和拦截器的差异,拦截器的一个好处就是能够知道本次请求执行的控制器和方法,而中间件有直接中断请求的能力,那么,把这两个能力结合起来,也就成为了守卫。
所以,从实际的使用体验来看的话,守卫更像是一个特殊的拦截器,守卫可以直接使得请求无法到达控制器的方法,进而避免一些无用的操作。
在NestJS中使用守卫
在NestJS中,守卫一般不会单独使用,因为如果你不依赖控制器上的元数据的话,那么你完全可以使用中间件完成相应的业务逻辑。
因此,在使用守卫之前,我们会给控制器定义元数据,比如,我有一个控制前下面的所有方法都必须使用token才可以访问,首先我们得给这个控制前标记一下身份,一会儿当用户的请求过来的时候,才好进行辨别。
以下是我结合我所开发的项目一个实际的使用守卫的例子。
首先,我们先自定义一个装饰器,将来凡是使用这个注解进行装饰的控制器或者控制器的方法,那么必须要经过鉴权操作才能进入到方法的执行。
ts
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { AUTH_REQUIRED_PROP } from '../config';
/**
* 鉴权,可以挂载在控制器上,则整个控制器下的所有方法都鉴权,也可以仅挂在单个方法上,对单个方法鉴权
* @returns
*/
export const Auth = () => applyDecorators(SetMetadata(AUTH_REQUIRED_PROP, true));
在上述代码中,我为什么要使用applyDecorators
这个API呢,主要是为了向大家展示它可以用来进行封装自定义装饰器。
然后,在业务侧使用,对控制器追加装饰:
ts
import { Auth } from '@/common/decorators/auth.decorator';
import { Query } from '@nestjs/common';
@Controller('/collection')
export class CollectionController {
constructor(protected readonly collectionService: CollectionService) {}
@Get('/receive')
@Auth()
receiveCollectReward(
@Query('bookId') bookId: string,
@Query('itemKey') itemKey: string,
) {
return this.collectionService.receiveCollectReward({
collectKey: bookId,
itemKey,
});
}
}
好了,准备好了守卫的依据之后,我们可以开始编写守卫了。
ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let authRequired = this.reflector.get<boolean>(
AUTH_REQUIRED_PROP,
context.getHandler(),
);
// 如果在方法上找不到,则尝试在控制器上
if (!authRequired) {
authRequired = this.reflector.get<boolean>(
AUTH_REQUIRED_PROP,
context.getClass(),
);
}
// 如果发现可以跳过鉴权
if (!authRequired) {
return true;
}
// 否则拿到用户的信息,进行鉴权操作
const req = context.switchToHttp().getRequest() as Request;
const method = req.method.toLowerCase();
let curUserId =
req.query.curuserid || req.query.userId || req.headers['x-user-id'];
let token = req.query.token || req.headers['x-token'];
if (!curUserId || !token) {
throw new NamedHttpException({
message: '未授权用户',
code: 401,
status: 200,
});
}
// 模拟调用是否校验通过的方法
const authResult = false
if (!authResult) {
throw new NamedHttpException({
message: '未授权用户',
code: 401,
status: 200,
});
}
return true;
}
}
最后,使用全局的API挂载它。
ts
@Module({
controllers: [AppController],
providers: [
{
useClass: AuthGuard,
provide: APP_GUARD,
},
],
})
export class AppModule {}
这儿,我使用的是IoC
容器帮我创建守卫实例的方式挂载。
与拦截器一样,守卫的挂载方式跟拦截器一样,也可以通过全局API的形式挂载:
ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard());
await app.listen(4000);
}
也可以使用UseGuards装饰器挂载,挂载在控制器上,则整个控制器都需要经过守卫,挂载在方法上,仅仅针对某个方法进行守卫。
比如:
ts
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
或者:
ts
@Controller('cats')
export class CatsController {
@Get('/hello')
@UseGuards(RolesGuard)
getHello(): string {
return 'hello world';
}
}
这儿,就有一个思维方式的差异了,就比如我给出的我在项目中的例子,我用的是全局守卫,然后是通过施加元数据的方式来决定请求是否应用拦截。
如果你是想使用UseGuards装饰器,然后在业务侧挂载对应的守卫,这个实现方式是一个不太好的编码方式了,为什么这么说呢?
因为你相当于是把通用业务逻辑分散到了各个业务代码之中,一旦守卫条件随业务进行调整的话,或者说增加守卫的话,那么势必就会造成大范围的代码修改,而如果像我在项目中的那种方式,实际上就将业务收敛到了一处,如果业务需要调整,仅仅需要在这个全局守卫里面变更条件判断即可,而不至于造成大范围的修改。所以建议大家在实际的项目中使用全局守卫的方式进行业务逻辑的处理。
守卫的原理分析
因为守卫和过滤器,拦截器的实现几乎是一模一样的,所以本文就不像前文那样分析的那么详细,如果你觉得一头雾水的话,可以先查看我之前的文章。
根据之前我们分析过滤器、拦截器时候的积累,我们能推测,使用IoC
容器创建的守卫或者使用useGlobalGuards
这类方式创建的守卫,都会添加到ApplicationConfig上面去,然后在路由处理的时候,扫描到控制器和方法上面的守卫,最后统一应用。
为什么能这样推导呢,因为在上文中我们就已经提到了,这是一个典型的模板方法模式的应用,因此我们就直接从守卫生效的位置开始看。
RouterExecutionContext这个类已经是我们熟悉的不能再熟悉的一个类啦,在这个位置创建守卫。 我们是可以明显的知道的是,如果守卫的canActive方法的返回值是false的话,控制器的方法就不用执行了。 如果返回值是false的话,我们得到的结果是一个403的错误,所以接下来可以看一下createGuardsFn这个方法到底做了什么。 守卫是被RouterProxy包裹着在执行的,如果里面执行的过程中抛出错误,立马就被过滤器捕获进入到过滤器的处理流程了,从而实现中断请求,这也应证了官网说法。
在前面两篇文章,我们已经提过了,ContextCreator是一个抽象类,拦截器,过滤器,守卫等各自实现它的抽象方法或重写某些方法,那么获取UseGuards的守卫,肯定还是先拿到的是控制器上的守卫,再拿到方法上的守卫。
但是,在守卫的消费者那个类里面,并没有任何逆序的操作,因此守卫生效的顺序也是全局守卫->控制器守卫->方法守卫 至此,我们就完全明白了守卫的工作流程了。
总结
实际项目中,尽可能都使用元数据+全局守卫的方式,应避免在控制器或方法上使用UseGuards进行守卫。
- 守卫的能力定位可以理解成一个能使请求中断的拦截器,因此守卫大多数情况下都用来做鉴权操作。
- 守卫的执行顺序是全局守卫->控制器守卫->方法守卫
- 多重守卫应用的时候,有一个守卫的返回值是false(如果是Promise,Awaited之后是false),后续的守卫将不会执行。
在下一篇文章中,我们会开始解析NestJS的Pipe
原理,敬请期待~