Nest采用了多种优秀的设计模式和软件设计思想,其中之一就是面向切面编程(AOP)。AOP的引入解决了传统面向对象编程中的一些问题,比如代码重复和业务逻辑的混杂。作为Nest最核心的设计理念之一,本节将深入学习AOP的工作机制及其应用。
Nest属于MVC(Model-View-Controller,模型-视图-控制器)架构体系,实际上,大多数后端框架都是基于这一架构设计的。在MVC架构中:
● Model层负责业务逻辑处理,包括数据的获取、存储、验证以及数据库操作。
● Controller层通常用于处理用户的输入,调度Service服务,以及进行API的路由管理。
● View层在传统的服务器端渲染中,可能使用如ejs、hbs等模板引擎。在前后端分离的体系中,通常指的是客户端框架(如Vue或React)负责的部分。
当一个HTTP请求到达服务器时,它首先会被Controller层接收。Controller层会根据请求调用Model层中的相应模块来处理业务逻辑,并将处理结果返回给View层以进行展示。整个基础流程的示意图如图2-31所示。
图2-31 HTTP请求基本流程
在MVC架构的基础上,Nest还引入了AOP的思想,从而具备了面向切面编程的能力。我们经常听到后端开发者提到AOP切面,那么究竟什么是面向切面编程呢?接下来将给出答案。
以一个HTTP请求为例,客户端发送请求时首先会经过Controller(控制器)、Service(服务)、DB(数据访问或操作)等模块。如果想要在这些模块中加入一些操作,例如数据验证、权限校验或日志统计,应该怎么办呢?
首先,我们可能会想到在Controller中加入参数校验逻辑或者权限校验,如果不通过验证,就直接返回错误。这样看起来似乎没什么问题。但是,如果有多个功能模块都需要进行校验,并且权限校验的逻辑相同,那么是不是意味着需要在多个Controller中重复加入这段逻辑?显然,这样做会导致公共逻辑与业务逻辑耦合。有没有办法可以做到统一管理呢?
答案是有的,办法如图2-32所示。
图2-32 切面工作原理图
由图2-32可见,在Controller的前后都可以“切一刀”,用来统一处理公共逻辑,这样就不会侵入Controller、Service等业务代码。
事实上,在Nest中,请求流程可以换一种角度来看,如图2-33所示。
图2-33 Nest请求流程
中间的灰色区域属于AOP切面部分,包含Middleware(中间件)、Guard(守卫)、Interceptor(拦截器)、Pipe(管道)和Filter(过滤器)。它们都是AOP思想的具体实现。
前面我们了解了AOP思想的优势,接下来看看AOP在Nest中的应用。这里将以store-web项目作为演示,代码已经放到GitHub对应章节的目录下。
Nest的中间件默认是基于Express的,它在请求流程中的位置如图2-34所示。
图2-34 中间件的位置
中间件可以在路由处理程序之前或之后插入执行任务,它们分为全局中间件和局部中间件两种类型。
全局中间件通过use方法调用,与Express中的使用方式类似。所有进入应用的请求都会经过全局中间件,通常用于执行日志统计、监控、安全性处理等任务。例如,在main.ts文件中使用全局中间件的方式如下,其中LoggerMiddleware是一个用于日志统计的中间件。示例代码如下:
async function bootstrap() { const app = await NestFactory.create(AppModule); // 中间件 app.use(new LoggerMiddleware().use) // 启动服务 await app.listen(8088); } bootstrap();
局部中间件通常应用于特定的控制器或单个路由上,以实现更细粒度的逻辑控制。例如,可以将LoggerMiddleware绑定到/person/create-person路由,并指定其适用的HTTP请求方法。以下是如何在控制器中应用局部中间件的示例代码:
对于中间件的更深入的知识,我们将在后续章节中进行详细介绍。
守卫的职责很明确,通常用于权限、角色等授权操作。守卫所在的位置与中间件类似,可以对请求进行拦截和过滤,如图2-35所示。
图2-35 守卫的位置
守卫在调用路由程序之前返回true或者false来判断是否通行,分为全局守卫和局部守卫。
守卫必须实现CanActivate接口中的canActivate()方法,代码如下:
全局守卫在main.ts中通过useGlobalGuards来调用,每个路由程序都会经过它进行权限验证才能够通行。
async function bootstrap() { const app = await NestFactory.create(AppModule); // 守卫 app.useGlobalGuards(new PersonGuard()) // 启动服务 await app.listen(8088); }
同样,与中间件类似,作为局部守卫,可以缩小控制范围,从而实现更加精细的权限控制。控制器中的示例代码如下:
@Controller('person') // 声明守卫 @UseGuards(new PersonGuard()) // 控制器 export class PersonController {}
拦截器不同于中间件和守卫,它在路由请求之前和之后都可以进行逻辑处理,能够充分操作request和response对象,如图2-36所示。拦截器通常用于记录请求日志、转换或格式化响应数据等。
图2-36 拦截器的位置
为了更好地说明拦截器的作用,下面的代码定义了一个用于统计接口超时的拦截器:
在上述代码中,拦截器必须实现NestInterceptor接口中的intercept方法。该方法包含与守卫相同的ExecutionContext上下文对象作为第一个参数。第二个参数是CallHandler,它代表每个路由处理程序的方法。只有当拦截器调用了handle方法后,控制权才会交给路由处理程序。在Nest中,拦截器通常与RxJS异步处理库一起使用,以执行一些异步逻辑,例如上例中的超时(timeout)统计。
类似于守卫,拦截器可以设置为控制器作用域、方法作用域或全局作用域。
(1)控制器作用域允许拦截器只作用于某个控制器。当程序执行到控制器时触发拦截器逻辑,通过@UseInterceptors装饰器将TimeoutInterceptor绑定到控制器类,代码如下:
@Controller('person') // 为控制器绑定超时拦截器 @UseInterceptors(new TimeoutInterceptor()) export class PersonController {}
(2)方法作用域把拦截器的作用范围限制在某个方法上,比全局或控制器级别的范围更加精确。当程序执行到该方法时,触发拦截器的逻辑。绑定方式如下:
@Get() // 为单独的方法绑定超时拦截器 @UseInterceptors(new TimeoutInterceptor()) findAll() { return this.personService.findAll(); }
(3)全局作用域允许拦截器应用到整个应用中。在main.ts文件中,可以通过app.useGlobalInterceptors()方法进行绑定,代码如下:
async function bootstrap() { const app = await NestFactory.create(AppModule); // 全局超时拦截器 app.useGlobalInterceptors(new TimeoutInterceptor()); // 启动服务 await app.listen(8088); } bootstrap();
管道用于处理通用逻辑,其中两个典型的用例是处理请求参数的验证(validation)和转换(transformation)。在执行路由方法之前,会首先执行管道逻辑,并将经过管道转换后的参数传递给路由方法。它的位置如图2-37所示。
图2-37 管道的位置
尽管Nest框架中已经内置了一些管道(Pipes),例如ParseIntPipe,但有时我们可能需要实现特定的转换功能,比如将数字转换为八进制。在这种情况下,我们需要创建自定义管道。以下是一个自定义管道的示例代码:
自定义管道需要实现PipeTransform接口的transform()方法。其中,value表示需要处理的方法参数,而metadata则是描述该参数的元数据,用于标识其属于哪种类型,如'body'、'query'、'param'和'custom'。
除了自定义管道和内置管道外,通常我们还会结合第三方验证库,例如class-validator。在后面的章节中将会详细介绍这部分内容。
如果管道验证器验证失败,则需要抛出异常并回应给客户端。这时就需要使用异常过滤器来处理。
Nest中最为常见的是HTTP异常过滤器,通常用于在后端服务发生异常时向客户端报告异常的类型。目前内置的HTTP异常包含:
● BadRequestException
● UnauthorizedException
● NotFoundException
● ForbiddenException
● NotAcceptableException
● RequestTimeoutException
● ConflictException
● GoneException
● HttpVersionNotSupportedException
● PayloadTooLargeException
● UnsupportedMediaTypeException
● ...
它们都继承自HttpException类。当然,我们也可以自定义异常过滤器,并向前端返回统一的数据格式:
自定义异常过滤器需要实现ExceptionFilter接口的catch()方法来拦截异常。ArgumentsHost能够获取不同平台的传输协议上下文,用于访问request和response对象。
另外,@Catch()装饰器用于声明要拦截的异常类型,这里使用的是HttpException。异常过滤器可应用于控制器作用域、方法作用域和全局作用域,我们都会尝试一遍。
(1)将过滤器绑定到控制器,代码如下:
@Controller('person') // 绑定到控制器 @UseFilters(new HttpExceptionFilter()) export class PersonController {}
(2)将过滤器绑定到某个路由方法中,代码如下:
@Get() // 绑定到路由方法 @UseFilters(new HttpExceptionFilter()) findAll() { return this.personService.findAll(); }
(3)将过滤器绑定到全局中,代码如下:
async function bootstrap() { const app = await NestFactory.create(AppModule); // 全局异常过滤器 app.useGlobalFilters(new HttpExceptionFilter()); // 启动服务 await app.listen(8088); } bootstrap();
以上就是Nest实现AOP架构思想的几种方式。不同的切面解决不同场景下的通用逻辑抽离问题,从而实现了更加灵活和可维护的应用程序。