头图
由于图片和格式解析问题,可前往 阅读原文

从本篇文章开始讲解node中最为出色的框架——NestJS,为什么说它出色,想必市面上已经议论纷纷了吧。如果你熟悉Spring框架那nest也会让你轻而易举的理解,基于typescript装饰器结合IOC让nest的框架设计更加清晰明了

NestJS 是一个基于 Node.js 平台的现代化 Web 框架,它结合了 TypeScript、面向对象编程的思想和函数式编程的思想,提供了一种高效、可扩展的方式来构建可维护的 Node.js 应用程序,相比express、koa、egg等几种框架nest更有出色的架构能力

Nest中使用了大量的IOC设计模式,它将控制权(即对象的创建、依赖关系的管理等)从应用程序代码中转移出去,交由容器来管理。这样做可以降低应用程序代码的复杂度,提高代码的可维护性和可重用性。在IOC模式中,容器通过控制反转(即控制权从应用程序代码中反转过来)来管理对象的创建和依赖关系。这样做有利于解耦应用程序中的各个组件,提高代码的可维护性和可重用性。而依赖注入(Dependency Injection,DI)则是IOC模式的一种实现方式,它可以帮助将对象之间的依赖关系自动注入到类中,使得代码更加简洁、可读性更高。在传统的编程模型中,对象之间的依赖关系是在代码中直接编写的。例如,一个类可能需要创建其他类的实例,或者需要依赖其他类的实例来完成某些操作。这样做会导致代码之间的耦合度很高,难以进行单元测试和代码重用

Nest真正的实现了架构的能力,以至于现在它可以变成出色的框架得到业界的重视。内部提供了中间件、拦截器、过滤器、异常、管道、鉴权设计理念,都非常值得我们思考和学习。学习nest最重要的就是它的设计思想,如:基本的架构设计、控制反转原理等等,从本篇开始讲nest的基本使用、实践、原理等等

安装

# 1. git clone
➜ git clone https://github.com/nestjs/typescript-starter.git nestjs-starter

# 2. nest cli
➜ npm i -g @nestjs/cli
➜ nest new nestjs-template

程序入口

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
    // 返回应用实例 INestApplication
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

nest可以与任何的HTTP框架对接,这里有两个开箱即用的HTTP框架:expressfastify,通常使用大家熟悉的express。这里可以传入express类型:

import type { NestExpressApplication } from '@nestjs/platform-express';

const app = await NestFactory.create<NestExpressApplication>(AppModule);

Module

模块是nest应用程序的起点,至少都会有一个module;通过module来给应用程序提供元数据,通常根据功能来划分不同的模块

module中有4个属性他们都有自己的作用:

  • controllers:提供当前模块的控制器来定义路由处理HTTP请求
  • providers:给当前module提供可注入的数据,如相关的service
  • imports:引入其他依赖或模块,如引入子模块、第三方模块等等
  • exports:暴露当前模块中的部分内容,以便其他引用了当前模块的模块可以使用当前暴露出的内容
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  exports: [AppService],
  imports: [MockModule], 
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

controllers

controllers用来给当前模块提供路由控制器类,这类类都会被@Controller装饰器进行装饰,用来处理HTTP请求,控制器类并不直接参与业务,通常和业务分开

定义一个控制器类:

@Controller('cat') // 统一前缀
export class CatController {
  @Get() // GET: cat
  findAll() {}
}

存放到当前模块的的controllers中

@Module({ controllers: [CatController] })
export class CatModule {}

这样cat module就拥有了Cat控制器中的路由 GET: /cat,后面会详细对Controller展开讲解,你可以点击这里跳转阅读

providers

providers用来给当前模块提供可注入的元数据,通过提供可注入的标识,在当前模块内部就可以注入使用被提供的内容。这些元数据可以是类、函数、值类型,通常类都会使用@Injectable进行装饰来标识当前类可以被注入,被@Injectable装饰器进行装饰的元数据不需要使用@Inject显式注入,而没有使用@Injectable装饰器的元数据需要显式注入

通常provider被缩写成数组形式[AClass, BClass]这样的,其本质还是 { provide: name, useClass: value }这种形式,前面说了值支持多种类型,不同类型会使用不同的值字段,其中包括:useValue(值)useClass(类)useFactory(函数),可以根据这些类型自定义prividers

  • useValue:用来直接提供值类型的元数据

    @Module({
      controllers: [MockController],
      providers: [
        // 定义string类型的值
        {
          provide: 'APP_NAME',
          useValue: 'nestjs-template',
        },
        // 定义数组类型
        {
          provide: 'APP_VERSIONS',
          useValue: [1, 2, 3],
        },
      ],
    })
    export class MockModule {}
    
    // 在当前模块的controller中使用inject注入使用
    @Controller('mock')
    export class MockController {
      constructor(
        @Inject('APP_NAME') // 显式注入
        private readonly APP_NAME: string,
        @Inject('APP_VERSIONS')
        private readonly APP_VERSIONS: string[],
      ) {
        console.log(this.APP_NAME, this.APP_VERSIONS);
      }
    }
  • useClass:用来提供类类型的元数据,可以是可注入的类或者直接类的实例

    // 1. 定义可注入的类 Test
    @Injectable()
    export class Test {
      log() {
        console.log('test');
      }
    }
    
    // 2. 提供元数据
    @Module({
      controllers: [MockController],
      providers: [Test], // 直接写成数据形式或者下面方式
      // providers: [
      //   { provider: Test, useClass: Test }
      // ]
    })
    export class MockModule {}
    
    // 3. controller中使用
    @Controller('mock')
    export class MockController {
      constructor(
        private readonly testIns: Test, // 内部会查找当前模块所提供的providers标识进行注入
      ) {}
    }

    除此之外还可以使用类的实例:

    // Test类也不需要 @Injectable 进行装饰,其余使用方法一致
    
    @Module({
      controllers: [MockController],
      providers: [
        {
          provide: Test,
          useValue: new Test(), // 提供实例
        },
      ],
    })
    export class MockModule {}
  • useFactory:值可以使用函数,这样也方便动态控制值;初次之外还可以注入其他类作为函数的参数使用

    @Module({
      controllers: [MockController],
      providers: [
        {
          provide: 'FUNC_PROVIDER',
          useFactory: () => 'func_provider', // 函数provider
        },
      ],
    })
    export class MockModule {}
    
    // 在当前模块的controller中使用inject注入使用
    @Controller('mock')
    export class MockController {
      constructor(
        @Inject('FUNC_PROVIDER') // 显式注入
        private readonly FUNC_PROVIDER: string,
      ) {
        console.log(this.FUNC_PROVIDER);
      }
    }

    除此之外还支持异步函数、函数传参

    @Injectable()
    class XXX {
      create() {
        console.log('xxx create');
        return ['create'];
      }
    }
    
    
    @Module({
      controllers: [MockController],
      providers: [
        {
          provide: 'FUNC_PROVIDER',
          useFactory: () => 'func_provider',
          scope: 'x'
        },
      ],
    })
    export class MockModule {}

provider还支持生命周期的定义,默认情况下提供的元数据的生命周期是全局跟着整个应用程序实例化一次,其支持的生命周期范围有:DEFAULT、TRANSIENT、REQUEST

@Module({
  controllers: [MockController],
  providers: [
    {
      provide: 'USER_LIST',
      useFactory: (xxx: XXX) => xxx.create(),
      inject: [XXX], // 注入XXX 类,作为函数的参数
      scope: Scope.DEFAULT
    },
    {
      provide: "xxx",
      useFactory: async() => return Promise.resolve(), // 异步provider
      scope: Scope.REQUEST
    }
  ],
})
export class MockModule {}

三者区别:

生命周期作用范围
DEFAULT整个应用程序全局共享一个实例
TRANSIENT整个应用程序每个注入一个实例
REQUEST请求瞬间每个请求一个实例

imports

用来导入其他的模块,以便使用其他模块的服务和依赖。通常可以按功能划分模块或路由

import { UserModule } from './user/user.module';

@Module({
  imports: [UserModule], // 导入User模块
})
export class AppModule {}

exports

导出当前模块的依赖以便其他模块可以使用

@Module({
  providers: [ Test ],
  exports: [Test], // 这里导出Test provider
})
export class MockModule {}

@Module({
  imports: [MockModule],
})
export class AppModule {
  // 这里可以直接注入Test,而不需要定义 providers
  constructor(private readonly testIns: Test) {
    this.testIns.log();
  }
}

动态模块

使用动态模块可以在不同的模块之间共享配置,共享模块是一种用于组织和重用代码的机制。共享模块可以定义一组公共的、可重用的服务、控制器、管道、拦截器等,这些组件可以被其他模块引用和使用

  1. 创建共享模块

    @Module({})
    export class ShareModule {
      // 提供register方法,并返回固定格式
      static register(opts: { name: string }) {
        return {
          module: ShareModule,
          providers: [
            {
              provide: 'SHAREMODULE',
              // useValue: 'xxx',
              useValue: () => xxx(opts)
            }
          ]
        }
      }
    }
  2. 使用共享模块

    @Module({
      imports: [
        // 注册模块
        ShareModule.register({ name: "Jack" })
      ]
    })
    export class AppModule {}

Controller

控制器用来接受具体的路由,可以通过路径前缀、域名来区分具体的路由

可以手动创建controller文件,对于初学者也可以使用nestjs的cli命令创建:

# 创建名为 cat 的controller
➜ nest n controller cat

路由分组

  1. 路由前缀控制

    类型定义:

    declare function Controller(prefix: string | string[]): ClassDecorator;

    例子:

    import { Controller } from '@nestjs/common';
    
    // 以cats开头的路径会走这里,如:/cats/all
    @Controller("cats")
    export class AppController {}
    
    @Controller(["cats", "dogs"])
    export class AppController {}
  2. 域名控制

    类型定义:

    interface ControllerOptions {
        path?: string | string[];
        host?: string | RegExp | Array<string | RegExp>;
    }

    例子:

    import { Controller } from '@nestjs/common';
    
    @Controller({
      host: "https://blog.usword.cn"
    })
    export class AppController {}
    
    @Controller({
      host: "https://blog.usword.cn",
      path: "/frontend"
    })
    export class AppController {}

路由定义

在控制器中定义具体的路由,如查询所有的猫咪,GET /cat/all;nestjs中内置了HTTP规范的请求方法装饰器,可以在具体的路由方法中轻松使用请求方式

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

@Controller({ path: 'cat' })
export class AppController {
  // get请求 /cats/all
  @Get('/all')
  getAllCats() {
    return [];
  }
  
  // post请求 /cat 新增一个猫
  @Post()
  createCat() {
    return true;
  }
  
  // 动态路由
  // get /cat/1
  @Get(':id')
  getCatById() {
    return { id: 1, name: '小白' };
  }
  
  // 通配符匹配
  // get /cat/ab_cd
  @Get('ab*cd')
  getOne() {
    return { id: 1, name: '小白' };
  }
}

除了get、post请求外,还有header、delete、options、patch等常见的http请求

参数处理

通常http请求时前端都会携带或多或少的数据传递给服务端,通过nestjs可以很好的拿到params、query、data等数据

  1. 处理param

    import { Controller, Get, Param } from '@nestjs/common';
    
    @Controller({ path: 'cat' })
    export class AppController {
      @Get(':id')
      getCatById(@Param() params: any) {
        // 拿到整个params  {id: xxx}
        console.log(params);
        return { id: 1, name: '小白' };
      }
      
      @Get(':id')
      getCatById(@Param('id') id: number) {
        // 只获取id
        console.log(id);
        return { id: 1, name: '小白' };
      }
    }
  2. 处理query

    import { Controller, Get, Query } from '@nestjs/common';
    
    @Controller({ path: 'cat' })
    export class AppController {
      @Get(':id')
      getCatById(@Query() query: any) {
        // 拿到整个query
        console.log(query);
        return { id: 1, name: '小白' };
      }
      
      @Get(':id')
      getCatById(@Query('id') id: number) {
        // 只获取query中的id
        console.log(id);
        return { id: 1, name: '小白' };
      }
    }
  3. 处理body

    import { Controller, Get, Body } from '@nestjs/common';
    
    @Controller({ path: 'cat' })
    export class AppController {
      @Post(':id')
      getCatById(@Body() body: any) {
        // 整个body对象
        console.log(body);
        return { id: 1, name: '小白' };
      }
      
      @Post(':id')
      getCatById(@Body("name") name: string) {
        // 获取body的name属性
        console.log(name);
        return { id: 1, name: '小白' };
      }
    }
  4. 处理表单数据(目前没有看到其它处理formdata的方法)

    import { Controller, Get, Body, UseInterceptors } from '@nestjs/common';
    import { FileInterceptor } from '@nestjs/platform-express';
    
    @Controller({ path: 'cat' })
    export class AppController {
    
      @Post(':id')
      @UseInterceptors(FileInterceptor('')) // 使用表单拦截器对数据进行表单转换,会得到json数据
      getCatById(@Body("name") name: string) {
        // 获取body的name属性
        console.log(name);
        return { id: 1, name: '小白' };
      }
    }

请求对象

在nestjs中一般不用获取原始的请求对象(request、response),使用nestjs提供工具就够用了如@Param、@Body等等;<u>在使用了原始的请求对象后就会脱离nestjs的本身,如果注入了Res那么必须手动执行res原始的响应,不然nest将会被挂起</u>

import { Controller, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';

@Controller({ path: 'cat' })
export class AppController {
  @Post(':id')
  // 注入 req,res
  getCatById(@Req() req: Request, @Res() res: Response) {
    console.log(req.query);
    // 使用原始的res进行数据响应
    res.status(200).json({ code: 200, msg: '原始请求对象响应数据...' });
  }
}

状态码

declare function HttpCode(statusCode: number): MethodDecorator;

例子:

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

@Controller('user')
export class UserController {
  @Get()
  @HttpCode(HttpStatus.OK)
  getUser() {
    return []
  }
}

头信息

declare function Header(name: string, value: string): MethodDecorator;

例子:

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

@Controller('user')
export class UserController {
  @Get()
  @Header('Cache-Control', 'none') // 设置cache-control头信息
  getUser() {
    return [];
  }
}
也可以使用底层库相应对象进行处理

重定向

declare function Redirect(url?: string, statusCode?: number): MethodDecorator;

例子:

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

@Controller('user')
export class UserController {
  @Get()
  @Redirect('https://blog.usword.cn', HttpStatus.MOVED_PERMANENTLY)
  getUser() {
    return [];
  }
}

子域路由

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

// host设置动态值 :host
@Controller({ host: ':host.usword.cn', path: 'cat' })
export class AppController {
  @Get() // hostparam获取域名的host值
  findCats(@HostParam('host') host: string) {
    console.log(host);
    return true;
  }
}

自定义装饰器

nestjs中除了提供一些常用的装饰器,难免会不满足业务中的使用,它也提供了自定义装饰器的功能,只要遵循一定的原则就可以实现自己的装饰器,本质底层还是装饰器和Reflect两者结合的结果

import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { Request } from 'express';

// 获取http协议的参数装饰器
export const Protocol = createParamDecorator(
  (defaultValue: string, ctx: ExecutionContext) => {
    if (defaultValue) return defaultValue;
    const request = ctx.switchToHttp();
    return request.getRequest<Request>().protocol;
  },
);

// 使用
import { Controller, Get } from '@nestjs/common';
import { Protocol } from 'src/common/decorator';

@Controller('mock')
export class MockController {
  @Get()
  async getList(@Protocol() protocol: string) { // 自定义参数装饰器
    console.log(protocol);
    return protocol;
  }
}

Scope作用域

controller也支持provider的作用域生命周期,其内部含义和provide类似

生命周期

nest在module、controller和injectable的模块代码提供了生命周期钩子事件

Lifecycle hook methodLifecycle event triggering the hook method call
onModuleInit()Called once the host module's dependencies have been resolved.
onApplicationBootstrap()Called once all modules have been initialized, but before listening for connections.
onModuleDestroy()*Called after a termination signal (e.g., SIGTERM) has been received.
beforeApplicationShutdown()*Called after all onModuleDestroy() handlers have completed (Promises resolved or rejected); once complete (Promises resolved or rejected), all existing connections will be closed (app.close() called).
onApplicationShutdown()*Called after connections close (app.close() resolves).

nestjs生命周期模型

Service

service通常都用来处理具体的业务,给应用程序提供数据,通过数据库增删改查然后返回给控制器

service通常是个被@Injectable装饰的类,提供给模块的providers做为源数据的标识,这样注入service就可以使用

service例子:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

@Module({
  imports: [],
  providers: [AppService],
})
export class AppModule {}

依赖注入

nestjs中使用了大量了依赖注入,使用@Injectable装饰的类表示可被注入,@Inject用来注入可注入的依赖。通常使用@Injectable装饰的类不需要显式的使用Inject进行注入,内部会自动进行注入。所有注入的依赖都比在当前模块的providers中提供注入的标识,更多类型的依赖可以查看providers

使用

// 定义可注入的类
@Injectable()
export class Test {
  log() {
    console.log('test');
  }
}

// 提供注入的标识 provider
@Module({
  controllers: [MockController],
  providers: [
    Test,
    // 非class的依赖
    {
      provide: 'APP_NAME',
      useValue: 'nestjs-template',
    },
  ],
})
export class MockModule {}

// 注入依赖
@Controller('mock')
export class MockController {
  constructor(
    // 不需要Inject显式注入,内部自动注入
    private readonly testIns: Test,
    @Inject('APP_NAME') // 显式注入
    private readonly APP_NAME: string,
  ) {}
}

作用域

Injectable支持三种类型的作用域:DEFAULT、TRANSIENT、REQUEST

可选项 Optional

如果程序中注入了某个依赖,而该模块没有提供当前依赖,那么就会报错,使用可选注入可以解决报错

// 注入依赖
@Controller('mock')
export class MockController {
  constructor(
    @Optional()
    @Inject('APP_NAME') // 模块没有注入不会报错
    private readonly APP_NAME: string,
  ) {}
}

应用配置

应用程序需要不同的配置文件分别在不同的环境进行运行,nestjs提供了@nestjs/config包来方便应用程序配置

在此包中包含了ConfigModuleConfigServiceregisterAs等几种工具方法,每个方法都有自己的作用

ConfigModule

ConfigModule用来加载配置文件,其包含forRootforFeature两个方法,前者为全局作用域,后者为当前面模块作用域

forRoot数据结构如下:

{
  envFilePath: string | string[]; // 定义env配置文件路径;默认加载根路径下的 .env
  ignoreEnvFile: boolean; // 是否忽略env文件
  isGlobal: boolean; // 是否为全局
  validationSchema: any; // 验证配置
  load: Array<ConfigFactory>; // 加载自定义的配置文件
  // ...
}

使用:

@Module({
  controllers: [MockController],
  // 引入模块,这是必须的
  imports: [
    ConfigModule.forRoot({
      envFilePath: resolve(__dirname, '../../.env'),
    }),
  ],
})
export class MockModule {}

ConfigService

加载好了配置后使用configService来获取配置

@Controller('mock')
export class MockController {
  constructor(
      // 注入ConfigService
    readonly configService: ConfigService<{ PORT: number }>,
  ) {
    const port = this.configService.get('PORT', 5000, { infer: true });
    console.log(port);
  }
}

通过将ConfigService注入到程序中,使用其提供的get方法获取变量,get的类型定义如下:

// 这里只举一个重载
get(key, defaultValue, options: { infer: boolean; /*类型推断*/ })

自定义配置

使用load加载自定义的配置:

// 可以将配置放到一个配置文件合理处理后导出
export const Configuration = () => ({
  PORT: process.env.PORT || 5000
});

@Module({
  controllers: [MockController],
  imports: [
    ConfigModule.forRoot({
      load: [Configuration], // 引入自定义配置
    }),
  ],
})
export class MockModule {}

registerAs 命名空间

对于前者的配置方案如果有嵌套的配置对于类型提示非常不友好(没嵌套也没类型推断),而且不同方面的配置冗余在一起对于后续维护不够友好,nest中提供了registerAs来定义命名空间

// 定义 db 命名空间
export const DatabaseConfiguration = registerAs('db', () => ({
  host: process.env.DB_HOST || 'localhost',
}));
@Module({
  controllers: [MockController],
  imports: [ConfigModule.forFeature(DatabaseConfiguration)], // 这里注册命名空间变量
})
export class MockModule {}

// 使用
@Controller('mock')
export class MockController {
  constructor(
    @Inject(DatabaseConfiguration.KEY) // 显式注入配置的KEY,这是必须的
    readonly dbConfiguration: ConfigType<typeof DatabaseConfiguration>,
  ) {
    console.log(this.dbConfiguration.host);
  }
}

校验

nestjs中可以使用joi对一些环境变量进行类型校验和定义检查,不合法就报错提示

安装相关依赖:

➜ yarn add joi

配置:

import * as Joi from 'joi';

@Module({
  controllers: [MockController],
  imports: [
    ConfigModule.forRoot({
      load: [Configuration, DatabaseConfiguration],
      validationSchema: Joi.object({
        PORT: Joi.number(),  // 必须是number
        NODE_ENV: Joi.string().valid('development', 'production', 'testing'), // 必填且必须是这几个值中的一个
      }),
    }),
  ],
})
export class MockModule {}
更多相关验证使用方式请访问文档

异步:forRootAsync({ useFactory: () => ({}) })

Middleware

middleware作用于应用路由,其生命周期为Request-Response,在middleware中必须调用next,不然程序请求将会挂起,内部基于koa的洋葱模型

使用middleware可以很轻松的创建通用的日志中间件

创建日志中间件,middleware需满足以下几点规则:

  • 使用@Injectable装饰作为提供者
  • 实现NestMiddleware类中的use方法
  • 必须调用next方法

创建配置

创建middleware:

# 1. 使用cli生成
➜ nest g middleware log --no-spec

# 2. 或者自己创建

配置middleware:这里实现在每次HTTP请求时打印请求日志,包括请求方法、地址、时间、耗时

// common/middleware/log.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { formatDate } from 'src/utils';

// 1. 可注入的
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  // 2. 实现NestMiddleware中use方法
  async use(req: Request, res: Response, next: (error?: any) => void) {
    const startTime = new Date();
    const method = req.method;
    const url = req.originalUrl;

    // 响应结束时打印相关耗时
    res.on('finish', () => {
      console.log(
        `${method?.toUpperCase()}: ${url}\t\t\t 时间: ${formatDate(
          startTime,
        )}\t 耗时: ${((+new Date() - +new Date(startTime)) / 1000)?.toFixed(
          3,
        )}s`,
      );
    });
    // 3. 必须调用next
    next();
  }
}

注册

middleware的注册可以针对不同的范围,你可以选择对所有的路由生效,或者只针对某个路由,也可以针对某个请求方法等等,注册非常灵活,有关更多注册方式你可以点击这里参考文档

@Module({
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  // 全局配置中间件 需要实现 NestModule中configure方法
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*'); // 针对所有路由
    // consumer
    //   .apply(LoggerMiddleware)
    //   .forRoutes({ path: 'mock', method: RequestMethod.GET });
    // consumer
    //   .apply(LoggerMiddleware)
    //   .exclude('user')
    //   .forRoutes({ path: 'mock*', method: RequestMethod.ALL });
  }
}

Exception Filter

nest内置异常层来捕获程序中没有处理的错误,如果程序中没有捕获错误将会在异常层被捕获。同时也提供了更加优化的异常使用如:HTTPException

nest中的filter、pipe、guard、interceptor可以在nest上下文以外、上下文中的module、controller、method以及param的位置使用,其中仅pipe支持在param位置使用

基本使用

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

@Controller('mock')
export class MockController {
  @Get()
  async getList() {
    // 主动抛出 500 异常
    throw new HttpException(
      '主动抛出友好的500错误',
      HttpStatus.INTERNAL_SERVER_ERROR,
    );
  }
}

// 当请求时返回一下结果
{
    "statusCode": 500,
    "message": "主动抛出友好的500错误"
}

HTTPException的类型定义如下:

constructor(response: string | Record<string, any>, status: number, options?: HttpExceptionOptions);

内置filter

除了通用的HTTPException外,还包含了HTTP的所有状态码类型的异常,这样就可以直接使用不同状态的异常不需要再指定具体的状态码了

  • BadRequestException
  • UnauthorizedException
  • NotFoundException

示例:

// 404
new NotFoundException('页面不见了');

自定义filter catch

异常捕获层默认会有固定的错误格式返回客户端,我们也可以根据自己的需求控制错误的格式。要实现自定义错误格式需要实现ExceptipnFilter类中的catch方法,并使用@Catch装饰器装饰

# 使用cli生成filter
➜ nest g filter HttpFilter --no-spec

这里做一个HTTP错误捕获处理格式:

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

@Catch()
export class HttpFilter implements ExceptionFilter {
  // 实现catch方法
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const code =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
    const errorException =
      exception instanceof HttpException
        ? exception?.getResponse?.()
        : exception;

    // 定义错误格式
    const error = {
      code,
      message:
        typeof errorException === 'string'
          ? errorException
          : (errorException as any)?.message || errorException,
      error: (errorException as any)?.error,
      timestamp: new Date().toISOString(),
    };
    console.error(`【HTTP_FILTER】`, exception);

    // 访问的是页面时渲染错误页面 (这里定义以api开始的代表接口数据)
    const isAccessPage = !/^\/(api|.*-api).*/i.test(request.originalUrl);
    if (isAccessPage) {
      // 渲染错误页面,对于页面模板的使用请查看后续的mvc文章
      return response.render('exception/index', {
        code,
        message: error.message || error.error,
      });
    }

    try {
      response.status(200).json({ ...error });
    } catch (error) {
      response.end();
    }
  }
}

绑定filter

在定义好HTTPException Filter后还需要绑定到应用程序中,前面说了filter、pipe、guard、interceptor可以在nest上下文以外、上下文中的module、controller、method以及param的位置使用,其中仅pipe支持在param位置使用。filter支持除param以外的位置绑定

  • 上下文以外

    // main.ts
    import { HttpFilter } from './common/filter/http.filter';
    
    function bootstrap() {
      // 省略其他...
      app.useGlobalFilters(new HttpFilter());
    }
  • module

    import { APP_FILTER } from '@nestjs/core';
    import { HttpFilter } from './common/filter/http.filter';
    
    @Module({
      providers: [
        {
          provide: APP_FILTER,
          useClass: HttpFilter,
        },
      ],
    })}
    export class AppModule {}
  • controller

    import { Get, HttpException, HttpStatus, UseFilters } from '@nestjs/common';
    import { HttpFilter } from 'src/common/filter/http.filter';
    
    @UseFilters(HttpFilter) // 使用 UseFilters装饰器绑定 filter
    @Controller('mock')
    export class MockController {
      @Get()
      async getList() {
        throw new HttpException(
          '主动抛出友好的500错误',
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      }
    }
  • method

    import { Get, HttpException, HttpStatus, UseFilters } from '@nestjs/common';
    import { HttpFilter } from 'src/common/filter/http.filter';
    
    @Controller('mock')
    export class MockController {
      @UseFilters(HttpFilter) // 使用 UseFilters装饰器绑定 filter
      @Get()
      async getList() {
        throw new HttpException(
          '主动抛出友好的500错误',
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      }
    }

以上不同的绑定方式作用的返回也不一样,应以实际的情况进行绑定,一般绑定到全局即可

当再次访问路由后,将会变成我们自定义的错误格式:

{
    "code": 500,
    "message": "主动抛出友好的500错误",
    "timestamp": "2020-07-22T06:24:49.140Z"
}

Pipe

pipe是nestjs中的管道技术主要用来对请求的数据进行转换和类型验证

基本使用

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

@Controller('mock')
export class MockController {
  @Get()
  async getList(
    // @Query('id', ParseIntPipe)
    @Query('id', new ParseIntPipe({ optional: true })) // id可选,如果不传id不会报错
    id: number,
  ) {
    console.log(typeof id); // number
    return id;
  }
}

绑定pipe

以上pipe绑定到了参数上,除了参数外,还支持nestjs上下文以外、module、controller、method

  • nestjs上下文以外

    app.useGlobalPipes(
      // 类型转换和验证pipe(常用)
      new ValidationPipe({
        // 删除发送过来的不存在的属性
        whitelist: true,
        // 将传过来的类型转换为定义的类型,转换为 string、number、boolean和自定义类型
        transform: true,
        transformOptions: {
          enableImplicitConversion: true, // 隐式转换
        },
      }),
    );
  • module

    import { Module, ValidationPipe } from '@nestjs/common';
    import { APP_PIPE } from '@nestjs/core';
    
    @Module({
      providers: [
        {
          provide: APP_PIPE,
          useValue: new ValidationPipe({
            // 删除前端发送过来的不存在的属性
            whitelist: true,
            // 将传过来的类型转换为定义的类型,转换为 string、number、boolean和自定义类型
            transform: false,
            transformOptions: {
              enableImplicitConversion: true, // 隐式转换
            },
          }),
        },
      ],
    })
    export class CommonModule {}
  • controller

    import { Controller, UsePipes, ValidationPipe } from '@nestjs/common';
    
    @UsePipes(new ValidationPipe({}), ParseIntPipe /* ... */)
    @Controller('mock')
    export class MockController {}
    controller、method都使用@UsePipes装饰器进行包装
  • method

    import { Controller, UsePipes, ValidationPipe } from '@nestjs/common';
    
    @Controller('mock')
    export class MockController {
      @UsePipes(new ValidationPipe({}), ParseIntPipe /* ... */)
      @Get(':id')
      async getOne(@Param('id') id: number) {
        return id;
      }
    }
  • param

    import { Controller, Get, Param, Body } from '@nestjs/common';
    
    @Controller('mock')
    export class MockController {
      @Get(':id')
      async getOne(
        @Param('id', ParseIntPipe) id: number, // 转换为 number
        @Body(new ParseArrayPipe({ optional: true })) users: string[], // 转换为string[]
      ) {
        cosnole.log(id, users);
        return id;
      }
    }

内置pipe

自定义pipe

除了内置的pipe外,nestjs中还支持自定义pipe,要实现自定义pipe需要实现PipeTransform类中的transform方法

# 使用cli生成pipe
➜ nest g pipe RequireId --no-spec

一个需要ID参数的Pipe:

import {
  ArgumentMetadata,
  HttpException,
  HttpStatus,
  Injectable,
  PipeTransform,
} from '@nestjs/common';

@Injectable()
export class RequiredId implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    const id = typeof value === 'object' ? value?.id : value;
    if (isNaN(parseInt(id))) {
      // 错误的客户端请求
      throw new HttpException('Id must be number', HttpStatus.BAD_REQUEST);
    }
    return parseInt(id);
  }
}

使用:

@Controller('mock')
export class MockController {
  @Delete(':id')
  async deleteOne(@Query(RequiredId) id: any) {
    console.log(id, typeof id);
    return true;
  }
}

Class-transformer、class-validator

nest中可以使用class-transformerclass-validator结合ValidationPipe进行数据格式转换和验证。更多使用详情查看这里以及响应序列化

创建数据模型:

// dto/pagination.dto.ts
import { Transform, Type } from 'class-transformer';
import { IsNumber, IsOptional, IsPositive } from 'class-validator';

export class PaginationQueryDto {
  @IsOptional() // 可选
  @Transform((val) => parseInt(val.value || 10)) // 对传入的值进行转换,转换失败默认10
  @IsPositive() // 大于0  // 判断必须大于0
  @Type(() => Number) // 类型为number
  @IsNumber() // number
  readonly limit: number = 10;

  @IsOptional()
  @Transform((val) => parseInt(val.value || 1))
  @IsPositive() // 大于0
  @Type(() => Number)
  @IsNumber()
  readonly offset: number = 1;
}

配置validationPipe

import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useValue: new ValidationPipe({
        // 删除发送过来的不存在的属性
        whitelist: true,
        // 将传过来的类型转换为定义的类型,转换为 string、number、boolean和自定义类型
        transform: false,
        transformOptions: {
          enableImplicitConversion: true, // 隐式转换,'1' ===> 1
        },
      }),
    },
  ],
})
export class CommonModule {}

在路由中使用:

@Controller('mock')
export class MockController {
  @Get()
  // 请求时会对query的数据进行验证,如果不符合 PaginationQueryDto 将会报异常
  async getList(@Query() query: PaginationQueryDto) {
    console.log(query);
    return query;
  }
}

这样对于请求时不合法的数据类型将会自动返回错误,如传入-1时:

{
    "code": 400,
    "message": [
        "limit must be a positive number" // 必须是个正整数
    ],
    "error": "Bad Request",
    "timestamp": "2020-07-22T07:50:32.472Z"
}

Guard

guard是nestjs提供给应用程序进行鉴权守卫的手段,取代传统的middleware鉴权处理。<u>guard在所有的middleware后执行,但会在所有pipe、interceptor前执行</u>

基本使用

一个guard需要实现CanActivate类的canActivate方法,当返回结果为true时表示有权限,反之没权限

# 使用ci生成guard
➜ nest g guard Authentication --no-spec

定义路由鉴权守卫:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { AuthenticationRouteKey } from '../decorator';
import { Request } from 'express';

/**
 * 控制某个路由守卫
 */
@Injectable()
export class AuthenticationRouteGuard implements CanActivate {
  // 获取元数据
  constructor(private readonly reflector: Reflector) {}

  // 实现 canActivate 方法
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 这里获取定义的元数据,因为这里的guard是针对某个路由的,元数据是在method上的,所以使用 getHandler 方法
    const isRouteAuthenrication = this.reflector.get(
      AuthenticationRouteKey,
      context.getHandler(),
    );
    // 判断当前请求路由不需要鉴权直接放行
    if (!isRouteAuthenrication) return true;
    // TODO: 需要鉴权的这里简单的使用 authorization 头值是否等于route进行判断
    // 通常获取用户信息进行鉴权
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    const key = request.headers.authorization;
    return key === 'route';
  }
}

使用守卫:

import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { RegisterAuthenticationRoute } from 'src/common/decorator';
import { AuthenticationRouteGuard } from 'src/common/guard/authentication-route.guard';

@Controller('mock')
export class MockController {
  @RegisterAuthenticationRoute() // 设置元数据
  @UseGuards(AuthenticationRouteGuard) // 设置路由守卫
  @Get('user/:id')
  getUserInfo(@Param('id') userId: string) {
    console.log(userId);
    return {
      name: 'Jack',
      age: 10,
      sex: 1,
    };
  }
}

// 设置元数据 RegisterAuthenticationRoute
export const AuthenticationRouteKey = 'Authentication-route-register';
export const RegisterAuthenticationRoute = () =>
  SetMetadata(AuthenticationRouteKey, true); // 主要是使用setMetadata方法
关于setMetadata的使用点击这里查看文档了解更多

绑定guard

上面使用@UseGuards在方法上进行了绑定,除此之外可以在Controller、module、上下文以外进行绑定,这里不再演示了

Interceptor

interceptor是nestjs提供的拦截器,通常用来对请求和响应数据进行拦截改造,很常见的axios对响应数据进行拦截处理,这里也通常对响应数据做一次包装进行返回,这样就可以形成统一的格式

# 使用cli生成interceptor
➜ nest g interceptor beauty-response --no-spec

自定义interceptor

自定以interceptor需要实现NestInterceptor类的intercept方法,该方法返回一个Observable类型数据,关于更多RxJS的使用,请查看相关文档

定义一个包装响应数据格式的拦截器:

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

@Injectable()
export class BeautyResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const response = ctx.getResponse<Response>();
    const contentType = response.getHeader('Content-Type');
    const isAttachment = response.getHeader('Content-Disposition');
    const isJSON = /(text|json)/i.test(contentType as string);
    // 流内容直接返回
    if ((contentType && !isJSON) || isAttachment) {
      return next.handle();
    } else {
      return next.handle().pipe(
          // 这里响应内容包裹了一层
        map(data => ({
          code: 200,
          data,
        })),
      );
    }
  }
}

绑定interceptor

interceptor也是支持nestjs上下文外全局绑定、module、controller、method等几种方式绑定,这里简单演示module绑定

import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { BeautyResponseInterceptor } from 'src/common/interceptor/beauty-response.interceptor';

@Module({
  privoders: [
    {
      provide: APP_INTERCEPTOR,
      useClass: BeautyResponseInterceptor,
    },
  ]
})
export class CommonModule {}

@Controller('api')
class TestController {
    @Get('users')
    getUsers() {
        return [1, 2, 3]; // 返回数据
    }
}

现在当访问GET: /api/users时会以我们定义的格式返回:

{
  "code": 200,
  "data": [1, 2, 3]
}
是不是体会到了NestJS的强大了

Logger

logger的使用这里不做解释,比较简单可以自行翻阅文档

版本控制

有时候需要两种版本api同时存在,这时候可以使用版本来标识不同的路由。nest提供了解决不同版本的方案

:::warning 温馨提示
对于前端来讲这种api版本场景可能很难遇到,此功能了解使用即可
:::

启用版本控制:

// main.ts
// 版本控制
app.enableVersioning({
  type: VersioningType.URI, // 使用URL版本控制  v1、v2、v3
});

版本控制支持多种形式的版本设置:url、header、media、自定义等等,这里使用URL来控制

基本使用

这里定义两个版本的方法来模拟不同版本

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

// @Version('2') // 也可以对controller进行版本区分
@Controller('mock')
export class MockController {
  // 版本控制
  @Version('1') // path: /v1/mock/version
  @Get('version')
  getVersion1() {
    return 'version1';
  }

  @Version('2') // path: /v2/mock/version
  @Get('version')
  getVersion2() {
    return 'version2';
  }
}

打开终端分别访问接口:

➜  ~ curl http://localhost:3000/v1/mock/version
{"code":200,"data":"version1"}
➜  ~ curl http://localhost:3000/v2/mock/version
{"code":200,"data":"version2"}

自定义版本

自定义版本就是获取版本的逻辑是如何的,以下是官方通过custom-versioning-field头信息获取版本的自定义版本提取器

const extractor = (request: FastifyRequest): string | string[] =>
  [request.headers['custom-versioning-field'] ?? '']
     .flatMap(v => v.split(','))
     .filter(v => !!v)
     .sort()
     .reverse();
app.enableVersioning({
  type: VersioningType.CUSTOM,
  extractor, // 自定义提取器
});

任务调度

nest中提供了任务调度系统允许我们在固定的时间或时间间隔执行任务方法,任务调度可以很好解决一些需要定时执行的任务

安装

➜ yarn add @nestjs/schedule
➜ yarn add @types/cron -D

模块注册

要使用任务调度需要导入任务调度模块

import { Module } from '@nestjs/common';
import { TaskScheduleController } from './task-schedule.controller';
import { ScheduleModule } from '@nestjs/schedule';

@Module({
  imports: [ScheduleModule.forRoot()], // 导入 ScheduleModule
  controllers: [TaskScheduleController],
})
export class TaskScheduleModule {}

注册调度任务

调度系统支持:Cron具体时间Interval间隔时间Timeout延时时间几种调度任务,其中cron更常用

  1. cron任务

    import { Controller } from '@nestjs/common';
    import { Cron } from '@nestjs/schedule';
    
    @Controller('schedule')
    export class TaskScheduleController {
      // 周一到周五早上11:30执行
      @Cron('0 30 11 * * 1-5', {
        name: 'cronTime', // 名字
        timeZone: 'Asia/Shanghai', // 时区
      })
      handleCron() {
        console.log('cron called...');
      }
    }

    Cron的时间是cron表达式

    * * * * * *
    | | | | | |
    | | | | | day of week
    | | | | months
    | | | day of month
    | | hours
    | minutes
    seconds (optional)
  2. Interval间隔任务

    interval间隔任务其实就是使用setInterval定义的任务

    import { Controller } from '@nestjs/common';
    import { Interval } from '@nestjs/schedule';
    
    @Controller('schedule')
    export class TaskScheduleController {
      @Interval('interval', 1000 * 60 * 5) // 间隔5分钟执行一次
      handleInterval() {
        console.log('interval called');
      }
    }
  3. Timeout延时任务

    timeout延时任务本质也是使用setTimeout定义的任务

    import { Controller } from '@nestjs/common';
    import { Timeout } from '@nestjs/schedule';
    
    @Controller('schedule')
    export class TaskScheduleController {
      @Timeout('timeout', 1000 * 60) // 60s 后执行
      handleTimeout() {
        console.log('timeout called');
      }
    }
    

动态调度

任务调度系统同时也提供了动态操作任务的功能,通常的业务中我们也会对的某个任务进行动态执行和注册,如请求某个接口新建一个任务或立即执行

任务调度的动态操作需要通过SchedulerRegistry进行,若要在controller中使用需要在构造器中注入

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

@Controller('mock')
export class MockController {
    // 注入 SchedulerRegistry
    constructor(private readonly scheduleRegistry: SchedulerRegistry) {}
}
  1. 动态处理cron任务

    @Controller('mock')
    export class MockController {
      // 注入 SchedulerRegistry
      constructor(private readonly scheduleRegistry: SchedulerRegistry) {}
      
      // 获取调度任务
      @Get('schedule/cron')
      handleScheduleCron() {
        // 获取指定的cron,并停止
        const job = this.scheduleRegistry.getCronJob('cronTime');
        job.stop();
        console.log('handleSchedule');
      }
    
      // 添加调度任务
      @Post('schedule/cron')
      handleAddScheduleCron() {
        const job = new CronJob('* * * * * *', () =>
          console.log('新增的调度任务执行。。。'),
        );
        this.scheduleRegistry.addCronJob('addCron', job);
        job.start();
    
        // 5s 后停止并删除添加的任务
        setTimeout(() => {
          job.stop();
          this.scheduleRegistry.deleteCronJob('addCron');
        }, 5000);
      }
    }
  2. 动态处理Interval任务

    @Controller('mock')
    export class MockController {
      // 注入 SchedulerRegistry
      constructor(private readonly scheduleRegistry: SchedulerRegistry) {}
    
      // 获取间隔任务 并取消
      @Get('schedule/interval')
      handleScheduleInterval() {
        // 获取指定的cron,并停止
        const job = this.scheduleRegistry.getInterval('interval');
        clearInterval(job);
      }
    
      // 添加间隔任务
      @Post('schedule/interval')
      handleAddScheduleInterval() {
        const cb = () => console.log('新增的间隔任务...');
        const interval = setInterval(cb, 1000);
        this.scheduleRegistry.addInterval('addInterval', interval);
    
        const intervalId = this.scheduleRegistry.getInterval('addInterval');
        // 5s后取消并删除
        setTimeout(() => {
          clearInterval(intervalId);
          this.scheduleRegistry.deleteInterval('addInterval');
        }, 5000);
      }
    }
  3. 动态处理Timeout任务

    @Controller('mock')
    export class MockController {
      // 注入 SchedulerRegistry
      constructor(private readonly scheduleRegistry: SchedulerRegistry) {}
    
      // 获取延时任务 并取消
      @Get('schedule/timeout')
      handleScheduleTimeout() {
        // 获取指定的cron,并停止
        const job = this.scheduleRegistry.getTimeout('timeout');
        clearTimeout(job);
      }
    
      // 添加延时任务
      @Post('schedule/timeout')
      handleAddScheduleTimeout() {
        const cb = () => console.log('新增的延时任务...');
        const timeout = setTimeout(cb, 2000);
        this.scheduleRegistry.addTimeout('addTimeout', timeout);
    
        const timeoutId = this.scheduleRegistry.getTimeout('addTimeout');
        setTimeout(() => clearTimeout(timeoutId), 5000);
      }
    }

总结

NestJS 是一个基于 Node.js 平台的现代化 Web 框架,它结合了 TypeScript、面向对象编程的思想和函数式编程的思想,提供了一种高效、可扩展的方式来构建可维护的 Node.js 应用程序

由于图片和格式解析问题,可前往 阅读原文

大卫talk
74 声望9 粉丝

攻粽号:码上来財(mslaicai)