NestJs 全面通俗介绍_新手快速入门的第一篇教程

3,209 阅读15分钟

Nest 是一个NodeJS服务端框架。区别于 Express/Koa/Fastify 没有(弱)主张的、纯粹提供HTTP服务器封装和异步流程串并行的思路的基础框架,NestJS提供架构主张,有自己一套架构模式,开发者需要按照NestJS要求的架构来组织代码。适合团队用于构建高效、可靠的、高可拓展、松散耦合、易于测试的大型NodeJS服务端应用。

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front and backend applications. This has given rise to awesome projects like Angular, React and Vue, which improve developer productivity and enable the creation of fast, testable, and extensible frontend applications. However, while plenty of superb libraries, helpers, and tools exist for Node (and server-side JavaScript), none of them effectively solve the main problem of - Architecture. Nest provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications. The architecture is heavily inspired by Angular. -- Documentation | NestJS - A progressive Node.js framework 官方的这段介绍和我看到的非常一致,注意作者说是高效地解决,我的理解是现在 Node.js 或者说 JavaScript 框架都是各做各的,都是些点,可能确实有做的很不错的,但是整体而言并没有一个把各种好东西串链起来做成一种通用模式的框架,或者说是架构。 -- keelii

Nest 基于 Express 或 Fastify 封装HTTP请求和路由,并支持其他 web 框架/库,并直接暴露他们的接口,使得开发者可以自由使用第三方库。Nest 使用 TypeScript 来编写程序,用了很多TS的写法如装饰器等特性,结合了 OOP、FP、FRP的相关理念,设计上很多灵感来自于 Angular 或者后端常用的Java技术栈 Spring 框架,如依赖注入、面向切面编程(AOP)等,可以说 Nest 是 Node.js 版的 Spring 框架。

支持使用脚手架 CLI 来生成项目架构

npm i -g @nestjs/cli
nest new project-name

项目目录

src
- app.controller.spec.ts
- app.controller.ts
- app.module.ts
- app.service.ts
- main.ts

分层架构

NestJS 倡导的架构与传统的MVC并不一致,Nest 更注重后端逻辑(控制器、服务与数据),视图层没有要求,比较独立,可由用户自定义配置。在MVC架构中,随着代码逐渐庞大,逻辑越来越多,Model层和Controller层的分工会变的模糊不清,导致难以维护,因此 Nest 借鉴了很多后端传统框架的分层设计。

1、Controller 层负责处理请求、返回响应。一个 Controller 负责接收并处理某个具体请求,由路由系统将请求分发给各controller处理。

2、Service 层负责提供方法和操作,只包含业务逻辑。例如CRUD操作应用数据、创建一个新用户等。Nest 借鉴了Angular (Spring) 依赖注入模式,可以将service注入到controller中作为服务使用。这样 Controller 和 Service 就能处于完全解耦的状态:Controller 做的事情仅仅是接收请求,并在合适的时候调用到 Service,至于 Service 内部怎么实现的 Controller 完全不在乎,也不用随着service的变更而跟着改变,达到解耦的效果。

3、Data Access 层负责访问数据库中的数据,比如使用ODM(Object Document Mapping)如Mongoose 访问数据库数据。

核心概念

1、Controller

负责接收请求,返回响应。与路由系统配合,路由系统决定那个请求使用那个 controller,期间可以调用 service。

Nest 使用 TS 的装饰器语法,将路由系统封装并提供接口,使得 controller 可以很方便的与路由系统结合,相较于 Express 等框架中心化的路由,Nest 路由是去中心化的, 而且与 controller 的结合非常方便:

import { Controller, Get, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Post()
  create(): string {
    return 'This action adds a new cat';
  }

  @Get('ab*cd')
  findAll(@Req() request: Request): string {
    return 'This action returns all cats';
  }
}

这其中可以对类、方法及参数都可以使用装饰器。对类使用 @Controller 装饰器将类作为一个 controller 与路由结合,对方法可以使用@Get/@Post作为请求方法处理器,对方法参数使用@Req/@Query等访问请求及请求内各部分数据。

2、Provider

Provider 是能够向别的类注入依赖的类,依赖可以是services, repositories, factories, helpers 等等。相当于提供服务的 service 层。Provider 是来自依赖注入的概念。

依赖注入:

模块化是分割代码的一种方式,依赖注入就是一种设计模式,用来连接模块并且管理好他们之间的依赖关系,做到模块间的高内聚低耦合。依赖注入在面向对象编程中比较常见,在面向对象编程中,引入的依赖通常是类,使用时就要创建这个类的实例去用,因此当一个类A依赖另外一个类B时,经常性的、一种不好的写法就是硬编码依赖,即类A引入类B并在A里创建B的实例来使用,然而当创建B实例的方式改变,A就不得不跟随着B改变。使用硬编码依赖,A引入的依赖越多,这种耦合就越严重。此外,依赖的实例会被重复创建。

此时可以引入一个第三方依赖管理容器,这个容器首先会包含注册的各种依赖,当A依赖B时,这个容器会找到B并实例化B,向A直接注入B的实例,A不必在内部实例化B就能直接使用B实例。这解决了硬编码依赖中的耦合及重复实例化问题,并且使各模块易于测试。

Nest 中 Controller 会经常调用 service 来实现业务逻辑,因此,Nest 使用依赖注入的方式来保证这两个层面的低耦合。在 Nest 中创建和使用一个 service:

// 创建 service.ts
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

// 使用 controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  //  Notice the use of the private syntax.
  // This shorthand allows us to both declare and initialize the catsService member immediately in the same location.
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

3、Module

Nest 采用模块化思路,将 APP 分割成各个模块,可以是专属功能的模块,也可以是通用的共享模块以及全局模块。APP 至少有一个根模块,包含定义的providers、controllers,repositories, interceptors, middleware, 等等,以及imports及exports。一个模块可以引用其他模块,也可以被其他模块引用。exports 可以导出providers(service)供其他模块使用

APP 由 各 Modules 组成,Modules 又由 controller、service分层,代码组织、职责划分清晰。这种模块化的组织方式,使我们可以将整个APP按照逻辑领域分割为各个模块,降低代码之间耦合的同时增强可拓展性。可以结合Domain-Driven Development。

声明一个模块:

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

4、middleware

Nest 底层基于Express来处理HTTP请求,也保留了Express中间件机制,因此Nest的中间件的定位、作用以及接口和 Express 是一致的,但不同的是, Nest 注册的中间件是在路由处理程序(如 Controller)之前调用的,而对于Express,中间件即是路由处理程序。

Nest 中声明一个中间件,就像provider一样,可以声明为一个服务:

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();
  }
}

注册中间件:使用 NestModule 接口提供的 configure 函数来为 module 或路由配置中间件。

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('cats');
      // .forRoutes('cats/:catName'); //指定中间件的应用路径/cats/:catName  
      //也可以通过如下方式指定包含中间件的请求方法
      // .forRoutes({ path: 'cats', method: RequestMethod.GET });
    
      //也可以使用通配符来匹配路径,如以下示例
      //forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });
    
     // 通过以下方法来针对不同控制器使用中间件,也可以传递一个由逗号分隔的控制器列表
     //.forRoutes(CatsController);
    
     // 通过exclude和路径方法来排除特定路径
     //.exclude(
     //{ path: 'cats', method: RequestMethod.GET },
     //{ path: 'cats', method: RequestMethod.POST })
  }
}

这里注册中间件的位置相较于Express在具体路由里注册的位置来说比较顶层,是因为可以使用 foreRoutes 为多个 controller、多个路由配置中间件,比较方便,因为中间件并不只是具体路由相关的。

5、Exception filters

Nest 框架内部提供一个异常处理机制,专门用来负责应用程序中未处理的异常。当错误未被捕获,就会被 Nest 的全局过滤异常器处理。这种机制跟Express差不多 。跟Express不同的是,Nest 内部提供了很多类型的错误类,用来抛出具体的错误。Nest 同样还可以自定义错误处理类:

// 声明
export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

// 使用 controller.ts
@Get()
async findAll() {
  throw new ForbiddenException();
}

通过自己定义并实现 Nest 提供的 ExceptionFilter 接口,可以自定义一个 Exception filter,能拿到Request、报错上下文等信息:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException) // 只捕获HttpException异常 不写参数捕获所有异常
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

然后在 controller 中绑定异常过滤处理器,也可以绑定在方法作用域、控制器作用域、全局作用域

@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

6、pipe 管道

Nest 中的 pipe 管道是用来为路由处理函数的参数(输入数据)做转换和校验,因此在路由处理函数之前被调用,然后将处理后的数据传给路由处理函数当作参数.

为啥会有这么个东西?

作为服务端应用程序,一类经常编写的功能就是请求数据处理和验证。尽管 Nast 使用 TypeScript,但只能做到代码运行前的静态检查,对于运行时的输入检查则无能无力。一些三方库如GraphQL、iO-ts、joi可以通过编写scheme来做到运行时检查。Nest 中的管道就是做运行时路由处理器输入数据的转换和检查。

我们可以直接写在路由处理程序中进行转换和校验,但这并不符合单一职责原则(路由处理程序做了多余的事);可以单独写一个类用来封装,但每个路由处理函数都要调用;可以写一个Nest中间件,但是Nest中间件是APP Module级别的,而特定路由处理器的参数处理和校验是非常具体的。因此,Nest 建立了 Pipe 机制,专门用来处理这类逻辑。

转换:

// 使用内置ParseIntPipe转换器
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

// 自定义
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}
// 使用 可以放在参数级别,也可以使用UsePipe放在方法级别
@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return this.catsService.findOne(id);
}

校验:

// 自定义校验器
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ObjectSchema } from 'joi'; // 使用joi生成校验函数

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private schema: ObjectSchema) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = this.schema.validate(value);
    if (error) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
}
// 使用
@Post()
// 可以放在参数级别,也可以使用UsePipe放在方法级别
@UsePipes(new JoiValidationPipe(createCatSchema))。
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
// CatScheme
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}
const createCatSchema = {
  name: Joi.string().required(),
  age: Joi.number().required(),
  breed: Joi.string().required(),
}

// 另外一种基于类的校验器 class-validator
npm i --save class-validator class-transformer

// 定义scheme
import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

// 定义校验器
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
// 使用 
@Post()
async create(
  // 仅绑定在作用于第一个参数
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

pipe 可以是同步也可以是异步的;

pipe 可以是参数级别的、方法级别、controller级别以及整个APP级别,非常灵活。

7、Guards 守卫

服务端应用程序另外一个常见的功能就是认证,例如权限、角色等。Nest 内置的Guards是专门用于做这些事情的,决定请求是否会被路由处理器处理。在 Express 这类中间件框架中,这种事情一般需要中间件去做,因为请求中的认证信息跟具体路由没有强关联性,但是中间件的一个坏处就是不知道下一个处理器是啥,不知道会做什么操作,顺序性、控制性不强。而 Nest 的 Guards 一是可以拿到上下文信息,知道接下来做什么,二是被放置在固定位置,三是声明式的代码更容易理解和清晰,是专门用来做认证的。

Guards 执行顺序是在所有的中间件执行完之后,在任何interceptor 或者 pipe之前执行.

定义和使用:

// 定义:必须实现canActivate接口,返回true或false决定是否会被路由处理器处理
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

// 使用
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

// 真实案例:配合角色使用
// 定义
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

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

  canActivate(context: ExecutionContext): boolean {
    // 获取绑定这个守卫的路由处理器的元数据;getHandler获取要被调用的路由处理器,
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}
// 使用
@Post()
@SetMetadata('roles', ['admin'])
@UseGuards(RolesGuard)
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

Guards 返回true/false,如果是false,或抛出ForbiddenException;

Guards 可以是方法级别、controller级别以及整个APP级别。

8、Interceptors 拦截器

面向切面编程是面向对象编程的一种补充。后端应用程序一般是分层架构,在面向对象世界中,各个功能又被划分为类,然而有一些各个类都会用到的通用操作如日志、鉴权、事务管理等(被称作交叉关注点),就会被重复地在每个应用程序、每个层、每个类中调用,就算把这些通用操作封装起来单独提供一个类,也会与其他类耦合,侵入型仍然较强,造成改动的联动。面向切面编程就是把这些交叉关注点抽离出来,单独封装作为一个功能,然后在代码编译或运行时动态的切入到某个执行点。

好处:

1、相同关注点可以抽离单独维护,重复使用;

2、不对其他功能有耦合和侵入型;

3、可以配置化决定是否注入/关闭注入;

Nest 中的 interceptor 用于提供切面编程机制,可以在路由处理函数执行时附近作为切入点:

  • 在函数执行前/后绑定额外的逻辑

  • 转换一个函数的返回值

  • 转换函数抛出的异常

  • 扩展基础函数的行为

  • 根据特定的条件完全的重写一个函数(比如:缓存)

实现的关键技术就是拿到切入点,Nest 使用 RxJS 的 Observable 拿到切入点:

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

@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`)),
      );
  }
}

// 使用
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

next.handle 就相当于要调用的路由处理函数,如果 intercept() 方法中没调用 handle() 方法,那么路由处理器将不会被执行,调用才会执行。

pipe代表路由处理器返回的结果,这样就可以利用 RxJS 的Observable提供的一系列操作函数对返回结果进行处理。

总结

1、自定义filters/pipe/guard/interceptor都需要实现特定的接口

2、filters/pipe/guard/interceptor 的作用域都可以是函数级别、controller级别和全局级别

3、filters/pipe/guard/interceptor 执行顺序

附录

1、ORM/ODM

ORM(Object/Relational Mapping) 对象/关系 映射

ODM(Object/Document Mapping) 对象/文档 映射

面向对象编程和关系型数据库,都是目前最流行的技术,但是它们的模型是不一样的。 面向对象编程把所有实体看成对象(object),关系型数据库则是采用实体之间的关系(relation)连接数据。ORM 就是通过实例对象的语法,完成关系型数据库的操作的技术。

ORM 使用对象,封装了数据库操作,因此可以不碰 SQL 语言。开发者只使用面向对象编程,与数据对象直接交互,不用关心底层数据库。

ODM就是通过实例对象的语法,完成对文档型数据库的操作的技术,比如Mongoose。

Read More

Nest.js — Architectural Pattern, Controllers, Providers, and Modules.

Why I choose NestJS over other Node JS frameworks

Nest.js 入手以及企业化的思考

Bulletproof node.js project architecture 🛡️

Documentation | NestJS - A progressive Node.js framework

Nestjs 框架教程(第一篇:简介)