【前端学习】 NestJS 之 守卫 & 拦截器 (Guards & Interceptors)

本文标题中的*!的意义均已在NestJS 安装部分提及: 【前端学习】 NestJS 之安装 (入门)

守卫 (Guards)

守卫 (guard) 是一个用@Injectable()装饰器注释的类,它实现了CanActivate接口。

守卫是有单一的责任的。它们会根据运行时存在的某些条件 (如权限、角色、ACL 等) 来确定给定请求是否将由路由处理程序处理。这通常会被称之为授权。中间件是身份验证的不错选择,因为像令牌验证或者将属性附加到request对象之类的工作,与特定路由上下文 (及其元数据) 没有紧密联系。

但是中间件,它是比较迟钝的。它并不知道在调用next()函数后将执行哪个处理程序。然而对于守卫来说,它可以访问ExecutionContext实例,所以它确切地知道接下来要执行什么。它的设计与异常过滤器、管道和拦截器非常相似,这可以让你在请求或者响应周期的正确位置插入处理逻辑,并以声明方式进行。它的好处是:有助于使代码保持干爽和声明式。

Tips:守卫会在所有中间件之后,任何拦截器或管道之前执行。

*授权守卫

授权对于Guards 来说是一个很好的用例。这是因为:当调用者 (一般是经过身份验证的用户) 拥有足够的权限时,特定的路由才应该可用。我们下方代码中要构建的AuthGuard,假设一个经过身份验证的用户 (在这里会将一个token 附加到请求标头)。它会提取并验证token (令牌),并使用提取的信息来确定请求是否可以继续。

/* auth.guard.ts */
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";

@Injectable()
export class AuthGuard implements CanActivate {
    canActivate(
        context: ExecutionContext,
    ): boolean | Promise<boolean> | Observable<boolean> {
        const request = context.switchToHttp().getRequest();
        return this.validateRequest(request);
    }

    private validateRequest(request: any): boolean | Promise<boolean> {
        console.log('Just try');
        return true;
    }
}

在这里的validateRequest()函数内的逻辑可以根据需要变得简单或者复杂,在这里只是做一个示范。上方的例子的要点是展示守卫如何适应 请求/响应周期。

不难看出,守卫必须实现一个canActivate()函数。此函数应该返回一个布尔值,指示是否允许当前请求。它可以同步或异步 (通过PromiseObservable) 返回响应。Nest 会使用返回值来控制下一步的动作:

  • 如果返回false,Nest 会拒绝该请求;

  • 如果返回true,Nest 会处理该请求。

执行上下文 (execution context)

canActivate()函数采用单个参数,即ExecutionContext实例。ExecutionContext继承于ArgumentsHost。在前面的异常过滤器章节中我们曾看到过ArgumentsHost;上方的示例中,我们使用了之前在ArgumentsHost上定义的相同辅助方法来获取对Request对象的引用。

ExecutionContext还添加了几个新的辅助方法,它们会提供有关当前执行过程的更多详细信息。通过这些信息有助于构建更加通用的守卫,这些守卫可以在广泛的控制器、方法和执行上下中工作。

*基于角色的身份验证

接下来我们会构建一个守卫,只允许具有特定角色的用户访问。我们将从一个基本的守卫模版开始,并逐步构建它。目前,它允许所有请求:

/* roles.guard.ts */

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";

@Injectable()
export class RolesGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        console.log('just try');
        return true;
    }
}

*绑定守卫

与管道和异常过滤器一样,守卫也可以是控制器范围、方法范围或全局作用域下。在这里我们会使用@UseGuards()装饰器来设置一个控制器作用域下的守卫。这个装饰器可以接受一个参数,或者逗号分隔的参数列表。这可以让我们通过一个声明轻松应用一组适当的守卫。

/* dogs.controller.ts */

// ...
import { RolesGuard } from "src/common/guard/roles.guard";

@Controller('dogs')
@UseGuards(RolesGuard)
export class DogsController {/* ... */}
// ...

我们通过传递了RolesGuard类 (并不是实例),将实例化的任务留给了框架并启用依赖注入。当然也可以与管道和异常过滤器一样,传递一个就地实例:@UseGuards(new RolesGuard())

上方的构造将守卫附加到此控制器声明的每个处理程序。如果希望守卫仅应用于单个方法,同样可以在方法级别应用@UseGuards()装饰器。

如果你想设置全局守卫,可以使用Nest 应用实例的useGlobalGuards()方法:

/* main.ts */
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

全局守卫用于整个应用,用于每个控制器和每个路由处理程序。在依赖注入方面,从任何模块外部注册的全局守卫 (例如上方的useGlobalGuards()) 不能注入依赖,因为这是在任何模块的上下文之外完成的。解决这个问题的方法是使用以下结构直接从任何模块设置守卫:

/* app.module.ts */

// ...
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {/* ... */}

Tips:当使用此方法对守卫执行依赖注入时,请注意,无论采用此构造的模块如何,守卫实际上是全局的

此时我们可以跑一下项目切换路由,在终端来看看结果:

绑定路由

! 设置角色

目前我们的RolesGuard很简单。接下来我们要利用最重要的防护功能 - 执行上下文。它还不知道角色,或者每个处理程序允许哪些角色。即DogsController可以针对不同的路由使用不同的权限方案。有些只对管理用户可用,另一些对所有人开放。所以应该如何以灵活且可重用的方式将角色与路由匹配?

这就是自定义元数据发挥作用的地方。Nest 提供了通过Refilector#createDecorator静态方法创建的装饰器或内置@SetMetadata()装饰器将自定义元数据附加到路由处理程序的功能。

接下来我们会使用Reflector#createDecorator 方法创建一个@Roles()装饰器,该方法将元数据附加到处理程序。Reflector由框架开箱即用,且从@nestjs/core包中公开。

/* roles.decorator.ts */

import { Reflector } from "@nestjs/core";

export const Roles = Reflector.createDecorator<string[]>();

这段代码表示:这里的Roles装饰器接收string[]类型的单个参数的函数。

如果要使用这个装饰器,则用它注释处理程序即可:

/* dogs.controller.ts */

// ...
import { Roles } from "src/common/guard/roles.decorator";

@Post()
@Roles(['admin'])
async create(@Body() createDogDto: CreateDogDto) {
    this.dogsService.create(createDogDto);
}
// ...

在这里我们将Roles()装饰器元数据附加到了create()方法上,表示只有具有admin角色的用户才能访问此路由。

提一嘴:另一个可行的方案是使用@SetMetadata()装饰器,而不是使用Refletor#createDecorator方法。

合并至一起

现在让我们将它与RolesGuard联系起来。目前,在所有情况下它均返回true,即允许所有请求继续进行。我们的需求是希望通过将分配给当前用户的角色与当前正在处理的路由所需的实际角色进行比较,依此来使返回值成为有条件的。为了访问路由的角色 (即自定义元数据) ,将再次使用Reflector辅助程序类:

/* roles.guard.ts */

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Roles } from "./roles.decorator";

@Injectable()
export class RolesGuard implements CanActivate {
    constructor(private reflector: Reflector) {}

    canActivate(context: ExecutionContext): boolean {
        const roles = this.reflector.get(Roles, context.getHandler());
        if (!roles) {
            return true;
        }
        const request = context.switchToHttp().getRequest();
        const user = request.user;

        return this.matchRoles(/* roles, user.roles */);
    }

    private matchRoles(/* allowedRoles: string[], userRoles: string[] */): boolean {
        return true;
    }

}

TipsmatchRoles()函数内的逻辑可以根据需要简单或者复杂 (这里直接返回了true,即通过;并且为了方便测试将对应传递的参数注释了 ) 。上方的例子的要点是展示守卫如何适应 请求/响应周期。

如果当权限不足的用户请求端点时,Nest 会自动返回以下响应:

{
  "message": "Forbidden resource",
  "error": "Forbidden",
  "statusCode": 403
}

只要当守卫返回false,就会抛出上方的错误。具体图片例子如下:

返回false 时的结果

守卫返回false时,框架会默认抛出ForbiddenException。如果你想返回不同的错误响应,你可以自己设置抛出的特定异常。如:

throw new UnauthorizedException();

结果会如下:

自定义异常

守卫抛出的任何异常都将由异常层 ( 全局异常过滤器 或 应用于当前上下文的任何异常过滤器 ) 来处理。

拦截器 (Interceptors)

拦截器 (Interceptor) 是用Injectable()装饰器注释并实现NestInterceptor接口的

拦截器具有一组有用的功能,它们可以:

  • 在方法执行 之前/之后 绑定额外的逻辑;

  • 转换函数返回的结果;

  • 扩展基本功能行为;

  • 根据特定条件完全覆盖函数 (例如:为了缓存) 。

基础

所有拦截器必须实现intercept()方法,它有两个参数。第一个是ExecutionContext实例 (很眼熟对吗?因为是与守卫完全相同的对象) 。ExecutionContext继承自ArgumentsHost。在这里,我们看到它是已传递给原始处理程序的参数的封装器,并且包含基于应用类型的不同参数数组。

Execution context

提一嘴

通过扩展ArgumentsHostExecutionContext添加了几个新的辅助方法,这些方法提供有关当前执行过程的更多详细信息。这些细节有助于构建更通用的拦截器,这些拦截器可以在广泛的控制器、方法和执行上下文中工作。

调用处理程序 (call handler)

第二个参数是CallHandler。这个接口实现了handle()方法,你可以使用它在拦截器中的某个点调用路由处理程序方法。如果在intercept()方法的实现中不调用handle()方法,则根本不会执行路由处理程序方法。

这种方法意味着intercept()方法有效地封装了 请求/响应 流。因此,你可以在最终路由处理程序执行之前和之后实现自定义逻辑。

举一个例子,考虑传入的POST /dogs请求。该请求发往DogsController内部定义的create()处理程序。如果在任何地方调用了一个不调用handle()方法的拦截器,则不会执行create()方法。一旦handle()被调用,create()方法就会被触发。

切面拦截 (Aspect interception)

首先来看的第一个例子是使用拦截器记录用户交互 (包括存储用户调用、异步调度事件 或 计算时间戳) 。下方简单展示了一个LoggingInterceptor

/* logging.interceptor.ts */

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, tap } from "rxjs";

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        console.log(`Before...`);

        const now = Date.now();
        return next
            .handle()
            .pipe(
                tap(() => console.log(`After... ${Date.now() - now}ms.`)),
            );
    }
}

Tips:拦截器,如控制器、提供器、守卫等,可以通过它们的constructor注入依赖。

上方的代码中,由于handle()返回一个RxJS Observable,所以可以使用多种运算符来操纵流。上方的示例中使用了tap()运算符,它在可观察流正常或异常终止时调用我们的匿名日志记录函数,且不会以其他方式干扰响应周期。

科普一下

  • RxJS ( Reactive Extensions for JavaScript ) :JavaScript 的响应式扩展,起源于Reactive Extensions,是一个基于可观测数据流 ( Observable Stream ) 结合观察者模式和迭代器模式的一种异步编程的应用库。RxJS 是Reactive Extensions 在JavaScript 上的实现,它提供了一种强大的方式来处理异步数据流和事件。

  • next.handle():这是拦截器链中调用下一个拦截器(如果存在)或目标处理器(如控制器中的方法)的入口点。handle()方法返回一个Observable,该Observable表示异步操作的完成,即目标处理器或者下一个拦截器的执行结果。

  • .pipe(...)pipe是RxJS中的一个非常核心的概念,它用于组合多个操作符( 如map、filter、tap 等 ),以创建一个新的Observable,这个Observable 会应用这些操作符的逻辑。

绑定拦截器

pipesguards一样,为了设置拦截器,我们会使用从@nestjs/common包中导入的@UseInterceptors()装饰器。拦截器同样可以是控制器作用域的、方法作用域的或全局作用域的。

/* dogs.controller.ts */

// ...
import { LoggingInterceptor } from "src/common/interceptor/logging.interceptor";


@UseInterceptors(LoggingInterceptor)
export class DogsController {/* ... */}

通过上述写法,DogsController中定义的每个路由处理程序都会使用LoggingInterceptor。比如当有人调用Get /dogs接口时,你会在控制台中得到以下输出,如图:

拦截器结果

与管道、守卫和异常过滤器一样,在这里我们传递了LoggingInterceptor类 (不是实例) ,我们将实例化的任务留给框架并启用依赖注入。当然我们也可以传递一个就地实例:@UseInterceptors(new LoggingInterceptor())

如果我们想将拦截器的范围限制为单个方法,我们只需在方法级别应用装饰器即可。

为了设置全局拦截器,我们可以使用Nest 应用实例的useGlobalInterceptors()方法:

/* main.ts */

const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());

全局拦截器用于整个应用,用于每个控制器和每个路由处理程序。在依赖注入方面,从任何模块外部注册的全局拦截器 (比如上方例子的useGlobalInterceptors()) 无法注入依赖,因为这是在任何模块的上下文之外完成的。为了解决这个问题 ( 聪明的你应该发现了,其实和pipeguard是一样的解决方法 ) ,可以使用以下结构直接从任何模块设置拦截器:

/* app.module.ts */

// ...
import { APP_INTERCEPTOR } from '@nestjs/core';

@Module({
  providers: [
    // ...
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
  // ...
})
export class AppModule { /* ... */}

Tips: 当使用此方法对拦截器执行依赖注入时,请注意,无论采用此构造的模块如何,拦截器实际上都是全局的。

响应映射 (Response mapping)

上方已经提及过,handle()会返回Observable。该流包含从路由处理程序返回的值,因此我们可以使用RxJS 的map()运算符轻松地改变它。

让我们创建TransformInterceptor,它会以简单的方式修改每个响应以演示该过程。通过使用RxJS 的map()运算符将响应对象分配给新创建对象的data属性,将新对象返回给客户端。

/* transform.interceptor.ts */

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";

export interface Response<T> {
    data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
    intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
        return next.handle().pipe(map(data => ({ data })));
    }
}

Tips:Nest 拦截器可与同步和异步intercept()方法一起使用。如果有必要,你可以将方法切换为async

当你将其绑定在@GET()上,然后通过上述构造,当调用 GET /dogs端点时,响应会如下 (假设路由处理器返回一个空数组[]):

{
	"data": []
}

结果如图:

TransformInterceptor

科普一下

Q:上方代码块中的T<T><Response<T>>是什么?

A:它们叫做类型变量,涉及到了几个关键的泛型 (Generic) 概念。它是一种特殊的变量,只用于表示类型而不是值。T帮助我们捕获用户传入的类型 ( 比如输入了string型 ) ,之后我们就可以使用这个类型。泛型的使用使得代码更加灵活和可重用。

  • T表示Response接口中data属性的类型,且可以包含任意类型的data,具体类型由使用TransformInterceptor时指定;

  • <T>是泛型参数的声明方式,它告诉编译器,接下来的类、接口或函数将使用泛型。尖括号<>内可以包含一个或多个泛型类型参数,这些参数在定义时未指定具体类型,但在使用时需要明确指定。在TransformInterceptor<T>中,<T>声明了这个拦截器是一个泛型拦截器,它可以处理任何类型的请求响应。但具体的类型 (T) 将在使用拦截器时确定。

  • <Response<T>>嵌套了另一个泛型Response<T>。在NestInterceptor<T, Response<T>>中,这个声明指定了拦截器的两个泛型参数:第一个T是拦截器处理的原始数据类型,第二个Response<T>是拦截器处理后返回的数据类型。这意味着拦截器将接收一个类型为T的数据,返回一个Response<T>类型的对象。其中Response对象的data属性类型与原始数据类型T相同。

拦截器在为整个应用中出现的需求创建可重用的解决方案方面具有很大的价值。打个比方,我们需要将每次出现的null值转换为空字符串'',就可以使用一行代码来完成,并全局绑定拦截器,以便每个注册的处理程序自动使用它:

/* example (can call it excludeNull.interceptor.ts) */

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(map(value => value === null ? '' : value ));
  }
}

异常映射 (Exception mapping)

我们也可以利用RxJS 的catchError()运算符来覆盖抛出的异常:

/* errors.interceptor.ts */

import { BadGatewayException, CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, throwError } from "rxjs";
import { catchError } from "rxjs/operators";

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        return next
            .handle()
            .pipe(
                catchError(err => throwError(() => new BadGatewayException()))
            );
    }
}

流覆盖 (Stream overriding)

我们有时可能想要完全阻止调用处理程序并返回一个不同的值。一个明显的例子是实现缓存以提高响应时间。

接下来让我们看一下一个简单的缓存拦截器,它从缓存返回其响应。在这里,只提供一个演示主要概念的基本示例。

/* cache.interceptor.ts */

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, of } from "rxjs";

@Injectable()
export class CacheInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        const isCached = true;
        if (isCached) {
            return of([]);
        }
        return next.handle();
    }
}

在该代码中,CacheInterceptor有一个硬编码的isCached变量和一个硬编码的响应[]。需要注意的是,我们在这里返回一个由RxJS of()运算符创建的新流,因此根本不会调用路由处理程序。当有人调用使用CacheInterceptor的端点时,将立即返回响应 (硬编码的空数组) 。为了创建通用解决方案,你可以利用Reflector并创建自定义装饰器 ( Reflector很眼熟对吗?因为它在guards章节出现过哦 ) 。

上方代码由于设置了isCachedtrue,我们将拦截器绑定在GET 请求上。所以即使我们通过POST 请求成功添加了数据之后,在浏览器访问/dogs端点时仍然只会显示[]

科普一下硬编码:是指在程序代码中直接写入具体的数据、配置信息或常量,而不是通过外部配置文件、数据库或用户输入等动态获取的方式来实现。硬编码通常被认为是一种不良的编程实践。

其他运算符

使用RxJS 运算符来操纵流的可能性为我们提供了非常多的功能。这里再举一个例子:你想要处理路由请求的超时。当你的端点在一段时间后未返回任何内容时,你希望以错误响应终止。以下结构可以实现这一点:

/* timeout.interceptor.ts */

import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from "@nestjs/common";
import { Observable, throwError, TimeoutError } from "rxjs";
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        return next.handle().pipe(
            timeout(5000),
            catchError(err => {
                if (err instanceof TimeoutError) {
                    return throwError(() => new RequestTimeoutException());
                }
                return throwError(() => err);
            })
        );
    }
}

在本拦截器被注册使用后,访问端口5 秒后,请求处理将被取消。你还可以在抛出RequestTimeoutException 之前添加一些自定义逻辑 ( 例如释放资源 ) 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值