这篇文章用于记录如何创建一个简单的Nest
应用服务。并且讲解了Module
以及几个Nestjs中几个内置的AOP特性,如:MiddleWare中间件
、Guard守卫
、Interceptor拦截器
、Pipe管道
以及filter过滤器
的相关概念。如有错误,欢迎支持!
相关代码见github
创建一个Nestjs应用服务
我们首先需要做的是以下操作
- 在全局安装nestjs的命令行工具
npm i -g @nestjs/cli
- 使用nestjs命令创建一个新的应用
nest new demo1-basic
然后会有一个交互窗口,让你选择对应的包管理器,我这里选择的是yarn
选择完后会得到一个这样的目录结构
demo1-basic
├── README.md
├── nest-cli.json
├── package.json
├── src
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
在这里,main.ts
就是我们的程序入口文件。它的内容如下:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
我们通过调用NestFactory.create
这个方法,传入一个AppModule
创建了一个app
实例。
Module
到这里,我们就可以看到Nest中的一个重要概念 Module(模块)
。nest以Module
为单位组织代码,通过@Module()
装饰器来定义模块。
一个Module
包含以下内容:
- imports: 模块依赖其他模块
- controllers:用于声明当前模块所管理的controller类的数组,比如我们
goods.controller.ts
中基于@Controller('/goods')
装饰的类就是一个controller类,用于管理路由(在请求的路径前加了个前缀,这是是/goods
),然后配合@Get
、@Post
这些来处理请求 - providers: 声明项目中的
Provider
,将它们注入到其他类中使用。而这里的Provider
可以理解为就是一些可重用的JavaScript类和对象。比如通过@Injectable
管理的类就可以被注入到其他类中,包括但是不限于Service
,我们下面的代码中会有体现,也就是依赖注入(DI)。 - exports:模块对外提供的方法类
AppModule
是项目的主模块,但是在一个应用中,通常是包含了很多模块。比如一个商城应用,可能包含用户、商品等模块。
这个时候,只写一个AppModule
就不适用了。所以针对我们上面的src/
的目录结构会改成类似如下:
├── src
│ ├── user
│ │ ├── user.controller.spec.ts
│ │ ├── user.controller.ts
│ │ ├── user.module.ts
│ │ ├── user.service.ts
│ ├── goods
│ │ ├── goods.controller.spec.ts
│ │ ├── goods.controller.ts
│ │ ├── goods.module.ts
│ │ ├── goods.service.ts
│ ├── app.module.ts
│ └── main.ts
我们使用命令行来生成我们需要的文件
- 生成
goods
模块
# 生成goods模块的 module 文件
nest generate module goods
# 生成goods模块的 controller 文件
nest generate co goods
# 生成goods模块的 service 文件
nest generate s goods
- 生成
user
模块
nest generate module user
nest generate co user
nest generate s user
我们可以看到,每次生成模块都会添加对应的Controller
和Service
。这是因为Controller
是对应模块的路由控制器,而Service
则通常用于实现与数据存储交互、处理业务逻辑、调用外部 API 或执行其他通用操作。
我们可以在上面代码的基础上修改以下两个文件
goods/goods.controller.ts
import { Controller, Get } from '@nestjs/common';
import { GoodsService } from './goods.service';
@Controller('goods')
export class GoodsController {
constructor(private readonly goodsService: GoodsService) {}
@Get()
findAll() {
return this.goodsService.findAll();
}
}
goods/goods.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class GoodsService {
findAll() {
return [
{ id: 1, name: 'iphone12', price: 2000 },
{ id: 2, name: 'iphone13', price: 3000 },
];
}
}
然后浏览器中访问 http://localhost:3000/goods 就会得到如下输出
对应到我们上面的代码,当我们访问http://localhost:3000/goods
时,首先:
- 请求进入到了我们写的应用程序中,访问
/goods
时 被nest 分到了GoodsController
下 - 然后通过
依赖注入
的goodsService
访问到了Service
类中的findAll
方法 - 最终返回了指定的数据
在上面,我们提到了依赖注入,在nestjs中,通过@Injectable()
将GoodsService
标记为一个Provider
然后,我们会在nest的Module
的providers
中注入Provider
(这一步,我们上面通过命令行生成的时候会自动写入,如果是自己创建文件,别忘记写):
@Module({
controllers: [GoodsController],
providers: [GoodsService],
})
export class GoodsModule {}
最后,我们就可以像上面的代码一样,在Controller
访问GoodsService
这个类对应的方法
中间件
中间件是在路由处理程序之前调用的函数
我们可以看官网中的例子,先创建一个middleware
logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Request...');
next();
}
}
使用中间件
中间件的使用无法通过在@Module
中注入,它需要通过实现NestModule
类中的configure
方法:
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('goods', 'user');
}
}
然后在请求loalhost:3000/goods
和localhost:3000/user
时就会输出Request
consumer
还有更多的配置,这些可以通过官网来看。
函数中间件
上面我们是通过实现NestMiddleware
接口的use
方法来生成中间件。但是该中间件并没有任何依赖。那么,就可以简单的使用函数中间件来实现上面的日志中间件
import { Request, Response, NextFunction } from 'express';
export function logger(req: Request, res: Response, next: NextFunction) {
console.log(`Request...`);
next();
};
然后在AppModule
中使用apply
应用中间件,如果是多个,可以按照以下的方式
consumer.apply(cors(), helmet(), logger).forRoutes(GoodsController, UserController);
但是上面有说到,如果有依赖(数据库连接, 配置等),比如要实现鉴权,日志入库,这些会连接数据库。那么我们就可以通过实现NestMiddleware
的方式来完成创建中间件,因为函数式中间件无法访问到依赖注入或是使用其他服务,或者比较复杂的错误处理
全局中间件
当想要一次性在所有路由上都绑定中间件时,可以通过全局中间件的形式绑定
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);
Guard 守卫
Guard
在所有的中间件之后执行,并且在pipe管道
、过滤器
、拦截器
之前执行,具体信息可以看文档。
虽然中间件也可以用作鉴权,但是它只能处理一些简单的场景,比如:根据请求头或者请求参数等基本信息进行简单地鉴定
而对于复杂的鉴权策略,需要考虑用户的角色、权限等复杂信息,需要更加灵活的方案来处理。
在这种情况下,Guard
提供了更加灵活和可定制的鉴权策略,可以通过自定义的逻辑实现复杂的鉴权场景,例如基于用户角色或权限、基于请求路径等等。通常,Guard
可以处理许多中间件不能处理的复杂情况。
因此,在处理复杂鉴权场景时,Guard
是一种更加灵活和可扩展的解决方案,可以提供更高级别的安全性和更好的用户体验。而对于一些简单的鉴权场景,使用中间件也是有效的方案。
写法
- 实现
CanActive
接口中的canActive
方法,并且需要返回一个boolean值,true则表示可以访问,false则拒绝该请求访问
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
// validateRequest就是你自己实现的鉴权方案
return validateRequest(request);
}
}
- 注册使用
- 具体到控制器、具体路由方法等
@Controller('goods') @UseGuards(AuthGuard) export class GoodsController {}
- 在main.ts中全局注册
const app = await NestFactory.create(AppModule); app.useGlobalGuards(new RolesGuard());
- 具体到控制器、具体路由方法等
拦截器Interceptor
拦截器是一种面向切面(AOP)
的编程技术,他有以下功能:
- 在请求之前/之后绑定额外的逻辑-
- 转换函数返回的结果
- 转换函数抛出的异常
- 扩展基本功能行为
- 根据特定条件完全覆盖函数(例如,出于缓存目的)
实现
下面实现一个全局拦截器,用于统一处理接口数据的返回格式
- 实现
NestInterceptor
接口的intercept
方法,intercept
方法有两个参数:第一个是上下文,第二个是CallHandler
,并且必须通过它调用handle()
方法,路由则无法被执行。
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
console.log('coming ResponseInterceptor interceptor');
return next.handle().pipe(
map((data) => ({
data,
status: 200,
extra: {},
message: 'success',
success: true,
})),
);
}
}
- 在
main.ts
全局注册(也可以在指定的路由方法中调用,可以根据具体情况编写不同的拦截器来使用)
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new ResponseInterceptor());
await app.listen(3000);
}
然后我们可以在postman中请求之前的http://localhost:3000/goods
,就会发现我们的数据中多了一层通用格式
{
"data": [
{
"id": 1,
"name": "iphone12",
"price": 2000
},
{
"id": 2,
"name": "iphone13",
"price": 3000
}
],
"status": 200,
"extra": {},
"message": "success",
"success": true
}
Pipe管道
管道有两个典型的用例:
- 转换 transformation:将输入数据转换为所需的形式(例如,从字符串到整数)
- 验证 validation:评估输入数据,如果有效,则简单地通过它;否则抛出异常
在nest中已经有了很多内置的Pipe
ValidationPipe
ParseIntPipe
ParseFloatPipe
ParseBoolPipe
ParseArrayPipe
ParseUUIDPipe
ParseEnumPipe
DefaultValuePipe
ParseFilePipe
我们可以模仿ParseIntPipe
来自定义一个Pipe
实现
- 实现
PipeTransform
接口的transform
方法,然后返回转换后的值。然后通过@Injectable()
装饰器将该类注入到nest中
@Injectable()
export class CustomParseIntPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
console.log('coming CustomParseIntPipe');
if (!this.isNumeric(value)) {
throw new BadRequestException(
'Validation failed (numeric string is expected)'
);
}
return parseInt(value, 10);
}
isNumeric(value) {
return (
['string', 'number'].includes(typeof value) &&
/^-?\d+$/.test(value) &&
isFinite(value)
);
}
}
- 注册
先在goods/goods.controller.ts
中新增加一个方法,用于通过id获取单挑数据,然后通过在@Param
使用对应的CustomParseIntPipe
即可在请求前完成数据的转换
@Get(':id')
findOne(@Param('id', CustomParseIntPipe) id: number) {
return this.goodsService.findOne(id);
}
goods/goods.service.ts
要添加一个findOne方法
const data = [
{ id: 1, name: 'iphone12', price: 2000 },
{ id: 2, name: 'iphone13', price: 3000 },
];
@Injectable()
export class GoodsService {
findAll() {
return data;
}
findOne(id) {
return data.filter((item) => item.id === id);
}
}
然后在postman中请求http://localhost:3000/goods/1
就能获取到正确的数据,如果没有这个CustomParseIntPipe
,接收到的id是一个string类型的,则无法找到对应的数据。
还有一个作用就是用来验证数据是否合法,比如如果我请求的是http://localhost:3000/goods/abc
,那么在进入到CustomParseIntPipe
会通过isNumeric
来判断当前的参数是否可以转为数字,如果不行,则直接报错如下信息
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}
filter过滤器
虽然基础(内置)异常过滤器可以为你自动处理许多情况,但有的时候,我们会希望对异常层进行完全控制。例如,你可能想添加日志或根据一些动态因素使用不同的 JSON 模式。而异常过滤器可以让我们很好的达成这个目的
HttpException
我们通过自定义创建了一个HttpExceptionFilter
过滤器,使用response.json()
对返回的json做了修改
import {
HttpException,
Catch,
ArgumentsHost,
ExceptionFilter,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const { name, message } = exception;
console.log('into HttpExceptionFilter ....');
response.status(status).json({
status,
timestamp: new Date().toISOString(),
path: request.url,
error: name,
message,
});
}
}
@Catch(HttpException)
装饰器将所需的元数据绑定到异常过滤器上,告诉Nest这个特定的过滤器正在寻找HttpException
类型的异常,而不是其他的。@Catch()
装饰器可以接受一个单一的参数,或者一个逗号分隔的列表。这让我们可以一次为几种类型的异常设置过滤器。
然后通过全局注册这个过滤器即可
app.useGlobalFilters(new HttpExceptionFilter());
全局异常捕获
在nest中,有一个内置的全局异常过滤器(filter)
,它处理HttpException
类型的异常(以及它的子类)。当一个异常未被识别时(既不是HttpException
,也不是继承自HttpException
的类),内置的异常过滤器会生成以下默认的JSON响应:
{
"statusCode": 500,
"message": "Internal server error"
}
我们可以改造goods/goods.controller.ts
中的findOne
方法,通过访问一个不存在的属性来人造一个异常。
@Get(':id')
findOne(@Param('id', CustomParseIntPipe) id: number) {
const a: any = {};
console.log(a.b.c);
return this.goodsService.findOne(id);
}
这个异常并不是HttpException
类的异常(以及它的子类),所以被内置异常过滤器捕获就会生成以上默认的响应。
不过我们可以通过自定义BaseExceptionFilter
来捕获这些非HttpException
的异常。
filters/base.exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
/**
* 捕获所有未处理的异常
*/
@Catch()
export class BaseExceptionFilter implements ExceptionFilter {
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
console.log('into BaseExceptionFilter ....');
const { name, message } = exception;
response.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
timestamp: new Date().toISOString(),
path: request.url,
error: name,
message,
});
}
}
从上面我们可以看到,该filter实现了ExceptionFilter
的catch方法,并对response做了对应的改变,让报错信息更明显。
还有就是为了捕捉每一个未处理的异常(不管异常类型如何),让@Catch()
装饰器的参数列表为空,例如:@Catch()
。
然后,我们再在main.ts
中全局注册
app.useGlobalFilters(new BaseExceptionFilter(), new HttpExceptionFilter());
这次,当我们再次访问http://localhost:3000/goods/1
时,就会发现返回的json变成了如下格式, 提示的非常的友好:
{
"statusCode": 500,
"timestamp": "2023-04-21T12:52:54.091Z",
"path": "/goods/1",
"error": "TypeError",
"message": "Cannot read properties of undefined (reading 'c')"
}