NestJS笔记-从入门到测试再到部署

本文章是NestJS笔记,涵盖了从入门到测试再到部署的所有内容。

前置知识:

1. 装饰器

1.1 什么是装饰器

装饰器是一种特殊的类型声明,它可以附加在类、方法、属性、参数上面,本质是一个函数

注意:装饰器写法需要开启一项配置

tsconfig.json

"experimentalDecorators": true

1.2 类装饰器

const doc: ClassDecorator = (target) => {
  // 使用该装饰器,会把该构造函数传入到函数的参数中
  console.log(target); // 输出的是xiaotian这个构造函数
  // 可以在原型上添加属性和方法
  target.prototype.name = "小天";
};

@doc
class xiaotian {
  constructor() {}
}

// 与上面的写法相同
// class xiaotian {
//     constructor() {

//     }
// }
// doc(xiaotian)

1.3 属性装饰器

const doc: PropertyDecorator = (target: any, key: string | symbol) => {
  // target 指向xiaotian原型对象,key是name
  console.log(target, key); // {} name
};

class xiaotian {
  @doc
  public name: string;
  constructor() {
    this.name = "小天";
  }
}

1.4 方法装饰器

const doc: MethodDecorator = (
  target: any,
  key: string | symbol,
  descriptor: any
) => {
  // target指向xiaotian原型对象,key是函数名,descriptor是描述符
  console.log(target, key, descriptor);
  /**
   * {} getName {
   *  value: [Function: getName],
   *  writable: true.
   *  enumerable: false,
   *  configurable: true
   * }
   */
};

class xiaotian {
  public name: string;
  constructor() {
    this.name = "小天";
  }

  @doc
  getName() {}
}

1.5 参数装饰器

const doc: ParameterDecorator = (
  target: any,
  key: string | symbol | undefined,
  index: any
) => {
  // target指向xiaotian原型对象,key是函数名,index是索引
  console.log(target, key, index);
  /**
   * {} getName 1
   */
};

class xiaotian {
  getName(name: string, @doc age: number) {}
}

2. 装饰器实现底层@Get

import axios from "axios";

const Get = (url: string) => {
  // 使用装饰器工厂(柯里化函数)
  return (target: any, key: any, descriptor: PropertyDescriptor) => {
    const fn = descriptor.value;
    axios
      .get(url)
      .then((res) => {
        fn(res);
      })
      .catch((e) => {});
  };
};

class Controller {
  constructor() {}

  @Get("https://api.com")
  getList(res: any) {
    console.log(res);
  }
}

一、NestJS 官方 cli

1. 快速上手

1.1 全局安装 cli

npm i -g @nestjs/cli

1.2 创建项目

nest new [项目名]

1.3 查看项目命令

  1. 查看 nest 命令
Usage: nest <command> [options]

Options:
  -v, --version                                   Output the current version. # 输出当前版本。
  -h, --help                                      Output usage information. # 输出使用情况信息。

Commands:
  new|n [options] [name]                          Generate Nest application. # 生成Nest应用程序。
  build [options] [app]                           Build Nest application. # 构建Nest应用程序。
  start [options] [app]                           Run Nest application. # 运行Nest应用程序。
  info|i                                          Display Nest project details. # 显示Nest项目详细信息。
  add [options] <library>                         Adds support for an external library to your project. # 将对外部库的支持添加到项目中。
  generate|g [options] <schematic> [name] [path]  Generate a Nest element. # 生成Nest元素。
    Schematics available on @nestjs/schematics collection: # 可以在@nestjs/示意图集合中找到示意图:
      ┌───────────────┬─────────────┬──────────────────────────────────────────────┐
      │ name          │ alias       │ description                                  │
      │ application   │ application │ Generate a new application workspace         │
      │ class         │ cl          │ Generate a new class                         │
      │ configuration │ config      │ Generate a CLI configuration file            │
      │ controller    │ co          │ Generate a controller declaration            │
      │ decorator     │ d           │ Generate a custom decorator                  │
      │ filter        │ f           │ Generate a filter declaration                │
      │ gateway       │ ga          │ Generate a gateway declaration               │
      │ guard         │ gu          │ Generate a guard declaration                 │
      │ interceptor   │ itc         │ Generate an interceptor declaration          │
      │ interface     │ itf         │ Generate an interface                        │
      │ library       │ lib         │ Generate a new library within a monorepo     │
      │ middleware    │ mi          │ Generate a middleware declaration            │
      │ module        │ mo          │ Generate a module declaration                │
      │ pipe          │ pi          │ Generate a pipe declaration                  │
      │ provider      │ pr          │ Generate a provider declaration              │
      │ resolver      │ r           │ Generate a GraphQL resolver declaration      │
      │ resource      │ res         │ Generate a new CRUD resource                 │
      │ service       │ s           │ Generate a service declaration               │
      │ sub-app       │ app         │ Generate a new application within a monorepo │
      └───────────────┴─────────────┴──────────────────────────────────────────────┘
  1. 查看 nest g 的命令
nest g --help
Usage: nest generate|g [options] <schematic> [name] [path]

Generate a Nest element.
  Schematics available on @nestjs/schematics collection:
    ┌───────────────┬─────────────┬──────────────────────────────────────────────┐
    │ name          │ alias       │ description                                  │
    │ application   │ application │ Generate a new application workspace         │
    │ class         │ cl          │ Generate a new class                         │
    │ configuration │ config      │ Generate a CLI configuration file            │
    │ controller    │ co          │ Generate a controller declaration            │
    │ decorator     │ d           │ Generate a custom decorator                  │
    │ filter        │ f           │ Generate a filter declaration                │
    │ gateway       │ ga          │ Generate a gateway declaration               │
    │ guard         │ gu          │ Generate a guard declaration                 │
    │ interceptor   │ itc         │ Generate an interceptor declaration          │
    │ interface     │ itf         │ Generate an interface                        │
    │ library       │ lib         │ Generate a new library within a monorepo     │
    │ middleware    │ mi          │ Generate a middleware declaration            │
    │ module        │ mo          │ Generate a module declaration                │
    │ pipe          │ pi          │ Generate a pipe declaration                  │
    │ provider      │ pr          │ Generate a provider declaration              │
    │ resolver      │ r           │ Generate a GraphQL resolver declaration      │
    │ resource      │ res         │ Generate a new CRUD resource                 │
    │ service       │ s           │ Generate a service declaration               │
    │ sub-app       │ app         │ Generate a new application within a monorepo │
    └───────────────┴─────────────┴──────────────────────────────────────────────┘

Options:
  -d, --dry-run                      Report actions that would be taken without writing out results. # 测试文件
  -p, --project [project]            Project in which to generate files.
  --flat                             Enforce flat structure of generated element.
  --no-flat                          Enforce that directories are generated.
  --spec                             Enforce spec files generation. (default: true)
  --spec-file-suffix [suffix]        Use a custom suffix for spec files.
  --skip-import                      Skip importing (default: false)
  --no-spec                          Disable spec files generation. # -g --no-spec 不创建测试文件
  -c, --collection [collectionName]  Schematics collection to use.
  -h, --help                         Output usage information.

例如:

  1. 生成一个user模块
nest g module user
  1. 生成一个user模块的controller
nest g controller user

2.创建第一个 NestJS 应用

项目目录结构

src
 |—— app.controller.spec.ts # 测试文件
 |—— app.controller.ts # 控制器:书写路由
 |—— app.module.ts # 根模块
 |—— app.service.ts # 书写逻辑部分
 |—— main.ts # 入口文件

3. 核心概念

Nestjs核心概念

4. NestJS 的生命周期

Nestjs生命周期

5. 编写接口

  1. app.controller.ts文件中:
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

// 该路由模块的基础路径
@Controller("api")
export class AppController {
  constructor(private readonly appService: AppService) {}

  // get 请求路径:/api/app
  @Get("app")
  getApp(): string {
    return "hello nestjs";
  }
}
  1. main.ts文件中,添加全局统一请求路径

使用app.setGlobalPrefix(xx),例如:app.setGlobalPrefix('/api/v1'),请求的路径就是:/api/v1/xxx

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("/api/v1");
  await app.listen(3000);
}
bootstrap();
  1. 将逻辑拆分到xx.service.ts

user.service.ts为例:

  • user.service.ts中:
import { Injectable } from "@nestjs/common";

@Injectable()
export class UserService {
  getUser() {
    return {
      code: 200,
      msg: "success",
      data: [],
    };
  }
}
  • user.controller.ts中:

将 userService 注入到容器中

constructor(private userService: UserService) {}

import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";

@Controller("user")
export class UserController {
  // 将userService注入到容器中(依赖注入)
  // 写法1:语法糖
  constructor(private userService: UserService) {}
  // 写法2:等价写法1
  userService: UserService;
  constructor() {
    this.userService = new UserService();
  }

  @Get("user")
  getUser(): Object {
    return this.userService.getUser();
  }
}

二、控制器 Controller

1. 路由通配符

@Get('ab*cd')
findAll() {
  return 'This route uses a wildcard';
}

2. 请求参数

使用注解的方式获取请求的参数

2.1 params 参数

请求路径:http://127.0.0.1:3000/api/v1/user/1

  • 写法 1:@Param()
@Get('/:id')
findParmas(@Param() params: any) {
    console.log(params);
    return 'success'
}

打印结果为:

{
  id: "1";
}
  • 写法 2:@Param('id')
@Get('/:id')
findParmas(@Param('id') id: any) {
    console.log(id);
    return 'success'
}

打印结果为:

1;

2.2 query 参数

请求路径:http://127.0.0.1:3000/api/v1/user?username=wifi&password=123

@Get()
findUser(@Query() query: any) {
    console.log(query);
    return 'success'
}

打印结果为:

{ username: 'wifi', password: '123' }

2.3 body 参数

请求路径:

POST /api/v1/user/body HTTP/1.1
Host: 127.0.0.1:3000
User-Agent: Apifox/1.0.0 (https://apifox.com)
Accept: */*
Host: 127.0.0.1:3000
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded

username=wifi&password=123
@Post('/body')
getBody(@Body() body: any) {
    console.log(body);
    return 'success'
}

打印结果为:

{ username: 'wifi', password: '123' }

2.4 headers 参数

请求路径:

添加了一个Authorization

GET /api/v1/user HTTP/1.1
Host: 127.0.0.1:3000
Authorization: ea341c68-0939-493f-824c-53bd76792a61
Host: 127.0.0.1:3000
@Get()
getHeaders(@Headers() headers: any) {
    console.log(headers);
    return 'success'
}

打印结果为:

{
  authorization: '6e8b0c8e-b9f4-4a22-a289-c15f43f0a705',
  host: '127.0.0.1:3000'
}

2.5 req 参数(所有参数)

@Get()
getReq(@Request() req: any) {
    console.log(req);
    return 'success'
}

会打印请求对象所有信息

3. 状态码

@Post()
@HttpCode(204)
create() {
  return 'This action adds a new cat';
}

4. 头部信息

@Post()
@Header('Cache-Control', 'none')
create() {
  return 'This action adds a new cat';
}

5. 重定向

@Get()
@Redirect('https://nestjs.com', 301)

三、模块 Module

1. 配置项

providers将由 Nest 注入器实例化并且至少可以在该模块中共享的提供程序享。
controllers此模块中定义的必须实例化的控制器集
imports导出此模块所需的提供程序的导入模块列表
exports这个模块提供的providers子集应该在引入此模块的其他模块中可用。您可以使用提供者本身,也可以只使用其标记(provide值)。

2. 全局模块

使用@Global装饰器

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

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

四、提供者 Providers

1. 基本使用(自定义名称)

自定义名称:useClass

  • user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  controllers: [UserController],
  // 简写形式
  providers: [UserService]
  // 完整写法
  providers: [
    {
      provide: '名称', // 自定义
      useClass: UserService
    }
  ]
})
export class UserModule {}
  • user.controller.ts

使用@Inject装饰器,传递的参数是provide自定义的名称

import { Controller, Get, Inject } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
    constructor(@Inject('userService') private userService: UserService) {}

    @Get('user')
    getUser(): Object {
        return this.userService.getUser()
    }
}

注意:在第二种(完整写法)上,在user.controller.ts中写的时候,才必须需要加@Inject装饰器,第一种简写形式不需要,如下:

import { Controller, Get, Inject } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
    constructor(private userService: UserService) {}

    @Get('user')
    getUser(): Object {
        return this.userService.getUser()
    }
}

2. 基本使用(自定义注入值)

自定义注入值:useValue

  • user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';

@Module({
  controllers: [UserController],
  providers: [
    {
      provide: 'hahaha',
      useValue: ['QQ', '微信', '抖音'] // 可以自定义类型
    }
  ]
})
export class UserModule {}
  • use.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';

@Controller('user')
export class UserController {
    constructor(
        @Inject('hahaha') private app: string[]
    ) {}

    @Get('user')
    getUser(): Object {
        return this.app
    }
}

3. 在一个 service 使用另一个 service

  1. 新建文件
  • 新建auth目录

  • 新建auth/auth.module.ts文件

  • 新建auth/auth.controller.ts文件

  • 新建auth/auth.service.ts文件

  1. app.module.ts中导入auth模块

  2. auth模块使用user模块的service

  • user.module.ts导出
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";

@Module({
  controllers: [UserController],
  providers: [UserService],
  // 要在auth模块使用,需要将userService导出
  exports: [UserService],
})
export class UserModule {}
  • auth.module.ts导入userService
// auth.module.ts
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UserService } from "src/user/user.service";

@Module({
  controllers: [AuthController],
  providers: [AuthService, UserService],
})
export class AuthModule {}
  • auth.controller.ts
import {
  Body,
  Controller,
  HttpException,
  HttpStatus,
  Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  signin(@Body() dto: { username: string; password: string }) {
    const { username, password } = dto;
    if (!username || !password) {
      throw new HttpException(
        "用户名或密码不能为空",
        HttpStatus.EXPECTATION_FAILED
      );
    }
    return this.authService.signin(username, password);
  }

  @Post("signup")
  signup(@Body() dto: { username: string; password: string }) {
    const { username, password } = dto;
    return this.authService.signup(username, password);
  }
}
  • auth.service.ts中使用
import { Injectable } from "@nestjs/common";
import { UserService } from "../user/user.service";

// service一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
  // 使用user
  constructor(private userService: UserService) {}

  signin(username: string, password: string) {
    let res = this.userService.getUser();
    return {
      code: 200,
      data: {
        username,
        password,
      },
      res,
    };
  }

  signup(username: string, password: string) {
    /**
     * 用户信息的ts类型
     * type User = {
     *      username: string,
     *      password: string,
     *      age: number;
     *      gender: string;
     * }
     * 但是在登陆注册时,不需要传递这么多参数,但是ts会报错,可以使用ts的Partial类型,将User类型转换为Partial类型,即所有属性都是可选的
     */
    return {
      code: 200,
      data: {
        username,
        password,
      },
    };
  }
}

五、异常过滤器 Exception Filter

1. 全局异常过滤器

1.1 异常过滤器-内置 http 异常Exception

官方文档 | NestJS - 一个渐进式的 Node.js 框架

1.2 基本使用

import { NotFoundException } from "@nestjs/common";

@Controller("user")
export class UserController {
  @Get("user")
  findAll(): Object {
    throw new NotFoundException("用户不存在");
  }
}
  • 响应结果
{
  "message": "用户不存在",
  "error": "Not Found",
  "statusCode": 404
}

1.3 进阶使用:全局异常过滤器

  • src目录下新建filtters目录,新建http-exception.filtter.ts文件
// src/filtters/http-exception.filtter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from "@nestjs/common";

@Catch(HttpException) // 捕获HttpException错误,如果为空则捕获所有错误
export class HttpExceptionFiltter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    // host表示整个nest进程
    // switchToHttp 可以找到整个程序的上下文
    const ctx = host.switchToHttp();
    // 响应、请求对象
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    // http状态码
    const status = exception.getStatus();
    // message
    const message = exception.message;

    // 返回给接口的数据
    // 重写状态码,改变响应体
    response.status(status).json({
      code: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      /**
       * 第3点,throw new NotFoundException("用户不存在");
       * 如果NotFoundException传入内容exception.message为传入的内容
       * 如果NotFoundException未传入内容,则默认为HttpException信息
       * */
      msg: exception.message || HttpException.name,
    });
  }
}
  • main.ts

useGlobalFilters全局过滤器

注意:全局过滤器app.useGlobalFilters只能有一个

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { HttpExceptionFiltter } from "./filtters/http-exception.filtter";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("/api/v1");
  // 将filtter配置成全局过滤器
  app.useGlobalFilters(new HttpExceptionFiltter());
  const port = 3000;
  await app.listen(port);
}
bootstrap();
  • user.controller.ts中使用
import { Controller, Get, NotFoundException } from "@nestjs/common";

@Controller("user")
export class UserController {
  @Get("user")
  findAll(): Object {
    // throw new NotFoundException()
    throw new NotFoundException("用户不存在");
  }
}
  • 请求接口返回的数据
{
  "code": 404,
  "timestamp": "2024-07-15T18:20:48.503Z",
  "path": "/api/v1/user/user",
  "method": "GET",
  "msg": "用户不存在"
}

2. 局部过滤器

  • 使用 cli 创建文件

filtters目录下创建名为typeorm.filter.ts的文件

nest g f filtters/typeorm --flat --no-spec
import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common";
import { QueryFailedError, TypeORMError } from "typeorm";

@Catch(TypeORMError) // 捕获TypeORM的错误
export class TypeormFilter implements ExceptionFilter {
  catch(exception: TypeORMError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    // 响应对象
    const response = ctx.getResponse();
    let code = 500;
    if (exception instanceof QueryFailedError) {
      code = exception.driverError.errno;
    }

    response.status(500).json({
      code: code,
      message: exception.message,
    });
  }
}
  • 在控制器中使用:user.controller.ts
import { Controller, Get, UseFilters } from "@nestjs/common";
import { TypeormFilter } from "../filtters/typeorm.filter";

@Controller("user")
// 使用局部过滤器
@UseFilters(new TypeormFilter())
export class UserController {
  @Get()
  getData() {}
}

也可以这样写:

import { Controller, Get, UseFilters } from "@nestjs/common";
import { TypeormFilter } from "../filtters/typeorm.filter";

// 这部分可以单独拆分到一个文件中,供其他模块使用
export function TypeOrmDecorator() {
  return UseFilters(new TypeormFilter());
}

@Controller("user")
// 使用局部过滤器
@TypeOrmDecorator()
export class UserController {
  @Get()
  getData() {}
}

六、管道 Pipe

1. 什么是管道

管道Pipe是具有 @Injectable() 装饰器的类。管道应实现 PipeTransform 接口

管道有两个典型的应用场景:

  • 转换:管道将输入数据转换为所需的数据输出(例如,将字符串转换为整数)
  • 验证:对输入数据进行验证,如果验证成功继续传递; 验证失败则抛出异常

在这两种情况下, 管道 参数(arguments) 会由控制器(controllers) 的路由处理程序进行处理。Nest 会在调用这个方法之前插入一个管道,管道会先拦截方法的调用参数,进行转换或是验证处理,然后用转换好或是验证好的参数调用原方法。

当在 Pipe 中发生异常,controller 不会继续执行任何方法

2. 管道类型

管道类型

3. nest 中创建校验类管道的过程(DTO 参数校验)

创建基于装饰器的校验的类管道,校验参数

  • dto使用PartialType,跟ts中的Partial一样,变为可选参数
import { PartialType } from '@nestjs/mapped-types';
import { CreateRoleDto } from './create-role.dto';

export class UpdateRoleDto extends PartialType(CreateRoleDto) {}

3.1 安装

使用到的第三方库:(类验证器)

  • class-transformer:转化请求数据为 DTO 类的实例

  • class-validator:使用正则等逻辑进行校验

npm i --save class-validator class-transformer

3.2 使用步骤

  1. 全局配置管道

main.ts

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("/api/v1");
  // 配置全局拦截器
  app.useGlobalPipes(
    new ValidationPipe({
      // 去除在类上不存在的字段
      whitelist: true,
    })
  );
  await app.listen(3000);
}
bootstrap();
  1. 创建 class 类,即 Entity、DTO

auth目录下新建dto目录,在dto目录下新建signin-user.dto.ts文件(用来校验登录接口的 dto 数据。

  1. 设置校验规则

signin-user.dto.ts

// signin-user.dto.ts
import {
  IsNotEmpty,
  IsString,
  Length,
  ValidationArguments,
} from "class-validator";

export class SigninUserDto {
  @IsString()
  @IsNotEmpty()
  @Length(6, 20, {
    // message?: string | ((validationArguments: ValidationArguments) => string)
    message: (validationArguments: ValidationArguments) => {
      const { value, property, targetName, constraints } = validationArguments;
      /**
       * value:当前用户传入的值
       * property:当前属性名
       * targetName:当前类
       * constraints:校验长度范围
       */
      console.log(value, property, targetName, constraints);
      // 打印结果:wifi username SigninUserDto [ 6, 20 ]
      return `用户名长度必须在${constraints}`;
    },
  })
  username: string;

  @IsString()
  @IsNotEmpty()
  @Length(6, 20)
  password: string;
}

如果校验不通过,返回给前端的数据为:

{
  "message": ["用户名长度必须在6,20"],
  "error": "Bad Request",
  "statusCode": 400
}
  1. 使用该实体类或者 DTO

在参数的后面加上定义的 DTO 类型SigninUserDto

auth.controller.ts中:

// auth.controller.ts
import {
  Body,
  Controller,
  HttpException,
  HttpStatus,
  Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SigninUserDto } from "./dto/signin-user.dto";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  // 对参数进行校验
  signin(@Body() dto: SigninUserDto) {
    const { username, password } = dto;
    return this.authService.signin(username, password);
  }

  @Post("signup")
  signup(@Body() dto: SigninUserDto) {
    const { username, password } = dto;
    return this.authService.signup(username, password);
  }
}

4. 参数转换

4.1 内置管道

Nest 自带九个开箱即用的管道,即

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

他们从 @nestjs/common 包中导出。

验证不是管道唯一的用处,管道也可以将输入数据转换为所需的输出。

4.2 简单使用

user.controller.ts

请求 url:http://127.0.0.1:3000/api/v1/123

import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";

@Controller("user")
export class UserController {
  constructor(private userService: UserService) {}

  @Get("/no/:id")
  async findOne(@Param("id") id: number) {
    // 这里Param参数获取到的id参数是一个字符串
    console.log(typeof id); // string
    return {
      id,
    };
  }

  @Get(":id")
  async findOne(@Param("id", ParseIntPipe) id: number) {
    console.log(typeof id); // number 将参数进行了转换
    return {
      id,
    };
  }
}

多参数(3 个以上)使用 DTO:详细看六、3.nest中创建校验类管道的过程

5. 自定义 pipe(多参数转换)

  1. 新建pipes目录,在pipes目录下新建create-user.pip.ts

  2. create-user.pip.ts

import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import { CreateUserDto } from "../dto/create-user.dto";

@Injectable()
export class CreateUserPipe implements PipeTransform {
  transform(value: CreateUserDto, metadata: ArgumentMetadata) {
    console.log(value);
    // { username: 'xiaotianwifi', password: '123456' }
    console.log(metadata);
    // { metatype: [class CreateUserDto], type: 'body', data: undefined }\

    // 对数据进行判断操作

    return value;
  }
}
  1. user.controller.ts中使用
import { Body, Controller, Post } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { CreateUserPipe } from "./pipes/create-user.pipe";

@Controller("user")
export class UserController {
  constructor(private userService: UserService) {}

  @Post("create")
  // 在Body装饰器中传递pipe
  createUser(@Body(CreateUserPipe) dto: CreateUserDto) {
    return "ok";
  }
}

6. 全局 pipe

  • 定义一个接口:

test.controller.ts

import { Body, Controller, Post } from "@nestjs/common";
import { IsNotEmpty, IsString } from "class-validator";

class TestDto {
  @IsString()
  @IsNotEmpty()
  msg: string;

  @IsString()
  id: string;
}

@Controller("test")
export class TestController {
  @Post()
  getTest(@Body() dto: TestDto) {
    console.log(dto);

    return "ok";
  }
}

注意:只有添加了装饰器的属性才不会被排除

// 不会被排除
@IsString()
id: string;

// 排除,和password一样
id: string;
  • 情景:当用户请求/test接口时,如果传递的Body参数为:
{
  "id": "1",
  "msg": "xiaotianwifi",
  "password": "123456"
}

而像password这样敏感的参数是后端不需要接受的,但是接口中的console.log(dto);打印的结果为:

{ "id": "1", "msg": "xiaotianwifi", "password": "123456" }

如果不需要password这样的参数:可以main.ts中配置全局管道

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    // 去除在类上不存在的字段
    whitelist: true,
  })
);

这样console.log(dto);打印的结果就只有需要的参数了

{ "id": "1", "msg": "xiaotianwifi" }

7. 提供默认值

@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}

8. 总结

  1. 校验参数:通过dto来校验,dto定义了参数的ts类型,结合class-validatorclass-transformer进行校验
  2. 参数转换:通过参数装饰器转换

8.1 校验参数

  • user.dto.ts
// user.dto.ts
import {
  IsNotEmpty,
  IsString,
  Length,
  ValidationArguments,
} from "class-validator";

export class UserDto {
  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  @Length(6, 20)
  password: string;
}
  • user.controller.ts
@Get()
async findAll(@Query('user') user: UserDto) {}

8.2 参数转换

@Get()
async findAll(@Query('id', ParseIntPipe) id: number) {}

@Get()
async findAll(@Query('id', new ParseIntPipe()) id: number) {}

七、守卫 Guards

守卫是一个使用 @Injectable() 装饰器的类。 守卫应该实现 CanActivate 接口。中间件不知道在调用next()函数后将执行哪个处理程序。另一方面,守卫可以访问ExecutionContext实例(上下文对象)。它们的设计方式与异常过滤器、管道和拦截器非常相似,目的是让您在请求/响应周期中的确切位置插入处理逻辑,并以声明的方式进行插入。

请注意,守卫在所有中间件之后执行,但在拦截器管道之前执行。

1. 基本使用

  1. src下新建guards目录,新建admin.guard.ts文件
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";

@Injectable()
export class AdminGuard implements CanActivate {
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
    // return false;
  }
}
  • 返回true:可以正常响应数据
{
  "msg": "success",
  "username": "xiaotianwifi",
  "password": "123456"
}
  • 返回false:响应错误
{
  "message": "Forbidden resource",
  "error": "Forbidden",
  "statusCode": 403
}

2. 使用守卫判断用户权限

这里关于req.userAuthGuard('jwt')的知识,可以查看十二 3.2校验token部分

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

@Injectable()
export class AdminGuard implements CanActivate {
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 1. 获取请求对象
    const req = context.switchToHttp().getRequest();
    // @Req() req这里的req.user是通过AuthGuard('jwt')中的validate方法返回的
    // PassportModule来添加的(自动添加的)
    console.log(req.user);

    // 2. 获取请求中的用户信息进行逻辑上的判断 -> 角色判断
    /**
     * 查询数据库是否有角色权限,如果有返回true,没有返回false
     */

    return true;
  }
}

非常重要的知识

  1. 装饰器的执行顺序:方法的装饰器如果有多个,则是从下往上执行
  2. 如果使用UseGuards传递多个守卫,则是从前往后执行,如果前面的 Guard 没有通过,则后面的 Guard 不会执行

2.1 装饰器的执顺序

装饰器的执顺序:从下到上

@Post('create')
@UseGuards(AdminGuard)
@UseGuards(AuthGuard('jwt'))
createUser(@Body() dto: CreateUserDto, @Req() req) {
    const { username, password } = dto;
    return this.userService.createUser(username, password);
}

上述代码,他会先执行:@UseGuards(AuthGuard('jwt')),再去执行@UseGuards(AdminGuard)

如果要在guards/admin.guards.tsconsole.log(req.user),则需要@UseGuards(AuthGuard('jwt'))放在下面,因为该装饰器会在request上加上user字段

2.2 UseGuards 传递多个守卫执行顺序

UseGuards 传递多个守卫执行顺序:从前往后

注意:如果前面的 Guard 没有通过,则后面的 Guard 不会执行

@Post('create')
@UseGuards(AuthGuard('jwt'), AdminGuard)
createUser(@Body() dto: CreateUserDto, @Req() req) {
    const { username, password } = dto;
    return this.userService.createUser(username, password);
}

3. 全局守卫

3.1 在main.ts中使用(不建议)

app.useGlobalGuards();

弊端:无法使用 DI,无法访问到 userService

3.2 在app.module.ts中使用

import { Module } from "@nestjs/common";
import { AuthModule } from "./auth/auth.module";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AdminGuard } from "./guards/admin.guard";

@Module({
  imports: [AuthModule, UserModule, ConfigModule.forRoot({ isGlobal: true })],
  controllers: [],
  providers: [
    {
      provide: APP_GUARD,
      useClass: AdminGuard,
    },
  ],
})
export class AppModule {}

八、拦截器 Interceptor

1. 基础知识

每个拦截器都有 intercept() 方法,它接收 2 个参数。 第一个是 ExecutionContext 实例(与守卫完全相同的对象)。 ExecutionContext 继承自 ArgumentsHostArgumentsHost 是传递给原始处理程序的参数的一个包装 ,它根据应用程序的类型包含不同的参数数组。可以用拦截器对数据脱敏处理。

2. 基本使用

  1. 新建interceptors目录,在interceptors目录下新建serialize.interceptor.ts文件
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from "@nestjs/common";
import { Observable, map } from "rxjs";

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    console.log("这是在拦截器执行之前", req); // 可以获取到request信息

    return next.handle().pipe(
      map((data) => {
        console.log("这里在拦截器执行之后", data);

        return data;
      })
    );
  }
}
  1. auth.controller.ts文件中使用
import { Body, Controller, Post, UseInterceptors } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SigninUserDto } from "./dto/signin-user.dto";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  // 拦截器
  @UseInterceptors(SerializeInterceptor)
  async signin(@Body() dto: SigninUserDto) {
    const { username, password } = dto;
    const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
    return {
      access_token: token,
    };
  }
}

1中的console.log('这里在拦截器执行之后', data);打印的 data 数据为:signin 接口返回的数据

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyNTA5NDYsImV4cCI6MTcyMTMzNzM0Nn0.yJzCw0elkOMoNPfFxZxzrTP5o-LmDbLxy9UvOGpjE4U"
}

3. 全局使用

main.ts

app.useGlobalInterceptors(new SerializeInterceptor());

4. 进阶使用(序列化)处理敏感数据

4.1 排除响应的属性

假设我们想要自动排除用户实体中的password属性。我们可以按以下方式对实体进行注解:

  • 新建auth.entity.ts文件

定义实体类型

import { Exclude } from "class-transformer";

export class SigninUserEntiy {
  username: string;

  @Exclude()
  password: string;

  constructor(partial: Partial<SigninUserEntiy>) {
    Object.assign(this, partial);
  }
}
  • auth.controller.ts
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): SigninUserEntity {
  return new SigninUserEntity({
    username: 'username',
    password: 'password',
  });
}

必须返回这个类的一个实例。如果返回一个普通的 JavaScript 对象,例如{ user: new UserEntity() },该对象将无法正确序列化。

import {
  Body,
  ClassSerializerInterceptor,
  Controller,
  Post,
  UseInterceptors,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
// 校验参数类型
import { SigninUserDto } from "./dto/signin-user.dto";
// 序列化参数
import { SigninUserEntiy } from "./auth.entity";

@Controller("auth")
// 使用拦截器 ClassSerializerInterceptor是nest内置的
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  async signin(@Body() dto: SigninUserDto) {
    const { username, password } = dto;
    // 返回的实例数据,才是序列化后的
    let signinUserEntity = new SigninUserEntiy(dto);
    const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
    return {
      access_token: token,
      info: dto,
      info2: signinUserEntity,
    };
  }
}
  • 发送请求:

curl

curl --location --request POST 'http://127.0.0.1:3000/api/v1/auth/signin' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
    "username": "xiaotianwifi",
    "password": "123456"
}'

响应结果

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEzMDcwMTcsImV4cCI6MTcyMTM5MzQxN30.XW8b6dekhRQ_ZONfzC9ZDaCqyUDiEM2ecN57nvGMQyU",
  "info": {
    "username": "xiaotianwifi",
    "password": "123456"
  },
  "info2": {
    "username": "xiaotianwifi"
  }
}

info是完整结果,info2是序列化后的结果

  • 该序列化也可在数据库实体部分中对数据直接进行过滤,在调用的时候会直接返回该实体,不需要手动new
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Exclude } from "class-transformer";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  @Exclude()
  password: string;
}

4.2 排除接受的数据

参考:六、6. 全局pipe

4.2 确保输出数据的安全:自定义拦截器

参考:八、5.自定义拦截器

5. 自定义拦截器

5.1 基本使用

  1. 新建interceptors目录,在interceptors目录下新建serialize.interceptor.ts文件
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { Observable, map } from "rxjs";

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    // console.log('这是在拦截器执行之前', req);

    // 属于后置拦截器
    return next.handle().pipe(
      map((data) => {
        console.log("这里在拦截器执行之后", data);

        // 对dto序列化
        return plainToInstance(this.dto, data, {
          // 设置为true,所有经过该拦截器的接口都徐亚设置@Expose或@Exclude
          // Expose就是设置哪些字段需要暴露,Exclude就是设置哪些字段不需要暴露
          excludeExtraneousValues: true,
        });
      })
    );
  }
}
  1. test.controller.ts中使用
import { Body, Controller, Post, UseInterceptors } from "@nestjs/common";
import { Expose } from "class-transformer";
import { IsNotEmpty, IsString } from "class-validator";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";

// 接收参数类型和校验
class TestDto {
  @IsString()
  @IsNotEmpty()
  msg: string;

  @IsString()
  id: string;
}

// 响应参数
class PublicDto {
  @Expose()
  msg: string;
}

@Controller("test")
export class TestController {
  @Post()
  // 响应的dto是PublicDto,因为该拦截器是后置拦截器
  @UseInterceptors(new SerializeInterceptor(PublicDto))
  getTest(@Body() dto: TestDto) {
    console.log(dto);

    return dto;
  }
}
  1. 结果
  • Body
{
  "id": "1",
  "msg": "xiaotianwifi",
  "password": "123456"
}
  • console.log(dto)

因为这里配置了全局管道:

app.useGlobalPipes(
  new ValidationPipe({
    // 去除在类上不存在的字段
    whitelist: true,
  })
);

所以打印结果为:

{ id: '1', msg: 'xiaotianwifi' }
  • 响应的数据:
{
  "msg": "xiaotianwifi"
}

5.2 自定义拦截器的装饰器

  1. 自定义拦截器部分和5.1基本使用 1一样

  2. 新建decorators目录,在decorators目录新建serialize.decorator.ts文件

import { UseInterceptors } from "@nestjs/common";
import { SerializeInterceptor } from "../interceptors/serialize.interceptor";

interface ClassConstructor {
  new (...args: any[]): any;
}

export function Serialize(dto: ClassConstructor) {
  return UseInterceptors(new SerializeInterceptor(dto));
}
  1. test.controller.ts中使用
import { Body, Controller, Post } from "@nestjs/common";
import { Expose } from "class-transformer";
import { IsNotEmpty, IsString } from "class-validator";
import { Serialize } from "../decorators/serialize.decorator";

class TestDto {
  @IsString()
  @IsNotEmpty()
  msg: string;

  @IsString()
  id: string;
}

class PublicDto {
  @Expose()
  msg: string;
}

@Controller("test")
export class TestController {
  @Post()
  // 自定义拦截器的装饰器
  @Serialize(PublicDto)
  getTest(@Body() dto: TestDto) {
    console.log(dto);

    return dto;
  }
}

6. 总结

在请求时,对不需要的 dto 字段进行排除,使用后置拦截器对响应的数据做出处理。dto 可以共用 entity 实例的,只需要在对应字段上加上注解即可。

九、中间件 Middleware

1. 什么是中间件

中间件是在路由处理程序之前调用的函数。中间件函数可以访问请求响应对象,以及应用程序的请求-响应周期中的next()中间件函数。通常,next中间件函数由一个名为next的变量表示。

中间件函数可以执行以下任务:

  • 执行任何代码。
  • 对请求和响应对象进行更改。
  • 结束请求-响应周期。
  • 调用堆栈中的下一个中间件函数。
  • 如果当前中间件函数未结束请求-响应周期,则必须调用next()将控制权传递给下一个中间件函数。否则,请求将被搁置。

2. 基本使用

  1. 新建user.middleware.ts文件
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";

@Injectable()
export class UserMiddleware implements NestMiddleware {
    use(req: Request, res: Response, next: NextFunction) {
        console.log('中间件执行了');
        next()
    }
}
  1. user.module.ts中使用
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserMiddleware } from './user.middleware';

@Module({
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    // 写法1:指定生效地址
    consumer.apply(UserMiddleware).forRoutes('user')
    // 写法2:对user路由的get请求进行处理
    consumer.apply(UserMiddleware).forRoutes({ path: 'user', method: RequestMethod.GET })
    // 写法3:对整个控制器处理
    consumer.apply(UserMiddleware).forRoutes(UserController)
  }
}

3. 全局中间件

全局中间件可以做接口的白名单

main.ts中:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Request, Response, NextFunction } from 'express';

function AppMiddleware(req: Request, res: Response, next: NextFunction) {
  // 可以做接口白名单
}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('/api/v1')
  app.use(AppMiddleware)
  await app.listen(3000);
}
bootstrap();

配置跨域中间件也可以跟express一样使用cors

十、多环境配置两种方案

1. dotenv

支持.env文件

1.1 安装dotenv

npm i dotenv

1.2 使用

  • 新建.env文件
TOKEN=1234567890
USERNAME=wifi
PASSWORD=123456
  • index.js使用
require("dotenv").config();
console.log(process.env);

打印的结果为:

{
    ...
  npm_config_prefix: '/usr/local',
  npm_node_execpath: '/usr/local/bin/node',
  TOKEN: '1234567890',
  USERNAME: 'wifi',
  PASSWORD: '123456'
}

2. config

支持yamlymljson文件

2.1 安装config

npm i config

2.2 json 文件格式(基本使用)

  • 新建config目录,在config目录下新建default.json
// config/default.json
{
  "token": "my-token",
  "db": {
    "host": "localhost",
    "port": 27017,
    "username": "root",
    "password": "123456"
  }
}
  • index.js使用
const config = require("config");
// 该db需要跟default.json的db对应
const dbConfig = config.get("db");
console.log(dbConfig);

输出结果为:

{
  host: 'localhost',
  port: 27017,
  username: 'root',
  password: '123456'
}

2.3 json 文件格式(进阶使用)

  • config目录下新建production.json文件(文件名不可自定义)
{
  "db": {
    "host": "www.wifi.com",
    "port": 8080
  }
}
  • 在终端中切换 node 运行环境,将环境换为production

winset NODE_ENV=production

macexport NODE_ENV=production

这时候他会合并default.jsonproduction.json的字段,以production.json为准,打印结果为:

{
  host: 'www.wifi.com',
  port: 8080,
  username: 'root',
  password: '123456'
}

2.4 yaml 文件格式

需要安装js-yaml,也会自动合并字段

  • default.yamlproduction.yaml
# default.yaml
token: my-token
db:
  host: localhost
  port: 27017
  username: root
  password: 123456
# production.yaml
db:
  host: www.wifi.com
  port: 8080
  • 运行index.js

结果为:

{
  host: 'www.wifi.com',
  port: 8080,
  username: 'root',
  password: 123456
}

2.5 corss-env 使用

可以在package.json中配置脚本环境

  • package.json
"scripts": {
  "pro": "cross-env NODE_ENV=production nodemon index.js", // production环境
  "dev": "cross-env NODE_ENV=development nodemon index.js" // development环境
}
  • config目录下三个文件

    • default.yaml
    # default
    token: my-token
    db:
      host: localhost
      port: 27017
      username: root
      password: 123456
    
    • development.yaml
    # development.yaml
    db:
      host: 127.0.0.1
      port: 7979
    
    • production.yaml
    # production
    db:
      host: www.wifi.com
      port: 8080
    
  • 执行脚本

    • npm run dev
    {
      host: '127.0.0.1',
      port: 7979,
      username: 'root',
      password: 123456
    }
    
    • npm run pro
    {
      host: 'www.wifi.com',
      port: 8080,
      username: 'root',
      password: 123456
    }
    

3. env 与 config 的优缺点

env适合简单配置,config适合嵌套复杂配置

十一、@nestjs/config(在 nest 中使用官方 config 设置模块)

1. 安装

npm i @nestjs/config

2. 基本使用

2.1 user 模块分别导入

  • app.module.ts中:
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";

@Module({
  imports: [
    UserModule,
    // forRoot 读取根目录下的.env文件
    // 在app模块的controllers和servivice中都可以使用,而在在跨模块的user模块中无法使用
    ConfigModule.forRoot(),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
  • user.controller.ts中:
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";

@Controller("user")
export class UserController {
  constructor(
    private userService: UserService,
    private configService: ConfigService
  ) {}

  @Get("user")
  getUser(): Object {
    let db = this.configService.get("DB");
    console.log(db);
    return this.userService.getUser();
  }
}

这个时候console.log(db)会报错,需要在user.module.ts中进行导入:

import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { ConfigModule } from "@nestjs/config";

@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

这个时候就可以正常读取根目录下的.env文件了

2.2 user 模块全局导入

  • app.module.ts中:

ConfigModule.forRoot()中配置isGlobal: true,表示全局使用

import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      // 配置isGlobal,可在全局使用
      isGlobal: true,
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
  • user.controller.ts中:
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";

@Controller("user")
export class UserController {
  constructor(
    private userService: UserService,
    private configService: ConfigService
  ) {}

  @Get("user")
  getUser(): Object {
    let db = this.configService.get("DB");
    console.log(db);
    return this.userService.getUser();
  }
}

3. 进阶使用(env 文件)

3.1 envFilePath结合cross-env

  • app.module.ts
import { Module } from '@nestjs/common';
import { UserModule } from './user/user.module';
import { ConfigModule } from '@nestjs/config';

const envFilePath = `.env.${process.env.NODE_ENV}`

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      // 配置isGlobal,可在全局使用
      isGlobal: true,
      // 加载指定路径的.env文件
      envFilePath: envFilePath
    })
  ],
  controllers: [],
  providers: [],
})
  • package.json
"scripts": {
  "start:dev": "cross-env NODE_ENV=development nest start --watch",
  "start:prod": "cross-env NODE_ENV=production node"
}

3.2 load结合dotenv使用

import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import * as dotenv from "dotenv";

const envFilePath = `.env.${process.env.NODE_ENV}`;

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      // 配置isGlobal,可在全局使用
      isGlobal: true,
      // 加载指定路径的.env文件
      envFilePath: envFilePath,
      // 读取.env文件作为公共配置文件
      load: [() => dotenv.config({ path: ".env" })],
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

4. 进阶使用(yaml 文件)

嵌套配置 yaml 文件读取

  • 安装js-yaml@types/js-yaml

js-yaml用于解析 yaml 文件,@types/js-yaml存放 js-yaml 文件类型声明

npm i js-yaml
npm i -D @types/js-yaml
  • 在根目录下新建config目录,在config目录下新建config.ymlconfig.development.ymlconfig.production.yml文件
# config.yml
db:
  mysql1:
    host: 127.0.0.1
    name: mysql-dev
    post: 3306

  mysql2:
    host: 127.0.0.2
    name: mysql-dev
    post: 3307
# config.development.yml
db:
  mysql1:
    name: mysql-dev1

  mysql2:
    name: mysql-dev2
# config.production.yml
db:
  mysql1:
    name: mysql-prod1

  mysql2:
    name: mysql-prod2
  • src目录下新建configuration.ts文件

安装lodash,用于拼接文件

npm i lodash
import { readFileSync } from "fs";
import * as yaml from "js-yaml";
import { join } from "path";
import * as _ from "lodash";

const YAML_COMMON_CONFIG_FILENAME = "config.yml";

// 读取yml文件路径
const filePath = join(__dirname, "../config", YAML_COMMON_CONFIG_FILENAME);
const envPath = join(
  __dirname,
  "../config",
  `config.${process.env.NODE_ENV}.yml`
);

const commonConfig = yaml.load(readFileSync(filePath, "utf-8"));
const envConfig = yaml.load(readFileSync(envPath, "utf-8"));

// 因为ConfigModule有一个load方法,需要传入一个函数
export default () => _.merge(commonConfig, envConfig);
  • app.module.ts
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import Configuration from "./configuration";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      isGlobal: true,
      load: [Configuration],
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
  • user.controller.ts
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";

@Controller("user")
export class UserController {
  constructor(
    private userService: UserService,
    private configService: ConfigService
  ) {}

  @Get("user")
  getUser(): Object {
    let db = this.configService.get("db");
    console.log(db);

    return this.userService.getUser();
  }
}

执行npm run start:prodconsole.log(db);的结果为:

# 环境不同,字段已经进行合并和覆盖
{
  mysql1: { host: '127.0.0.1', name: 'mysql-prod1', post: 3306 },
  mysql2: { host: '127.0.0.2', name: 'mysql-prod2', post: 3307 }
}

5. 进阶使用:配置文件参数校验 Joi

  • 安装
npm i joi
  • 使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { ConfigModule } from "@nestjs/config";
import * as Joi from "joi";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      // 环境参数校验
      validationSchema: Joi.object({
        // 默认3306,选择范围:3306、3309(只能是这两个)
        DB_PORT: Joi.number().default(3306).valid(3306, 3309),
        NODE_ENV: Joi.string()
          .valid("production", "development")
          .default("development"),
        DB_URL: Joi.string().domain(),
        DB_HOST: Joi.string().ip(),
      }),
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

6. 进阶使用:使用 nest 内置的方法生成配置项registerAs

  1. 新建database.ts

registerAs有两个参数,参数 1 是key,参数 2 是回调,返回一个对象

import { registerAs } from "@nestjs/config";

export default registerAs("database", () => {
  return {
    host: process.env.DATABASE_HOST || "localhost",
    port: process.env.DATABASE_PORT || 3306,
  };
});
  1. app.module.ts中注入
ConfigModule.forRoot({
  isGlobal: true,
  load: [database], // 注入后才能使用
});
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./user/user.module";
import { TypeOrmConfigService } from "./TypeOrmConfigService";
import { User } from "./user/user.entity";
import { ConfigModule } from "@nestjs/config";
import database from "./database";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      isGlobal: true,
      load: [database] // 注入后才能使用
    })
    TypeOrmModule.forRootAsync({
      useClass: TypeOrmConfigService
    }),
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [],
  providers: [],
})
export class AppModule { }
  1. 使用

通过databaseConfig.KEY注入

@Inject(databaseConfig.KEY) private config: ConfigType<typeof databaseConfig>
import { Inject, Injectable } from "@nestjs/common";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";
import { User } from "./user/user.entity";
import { ConfigType } from "@nestjs/config";
import databaseConfig from "./database";

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  constructor(
    // ConfigType可以通过内置方法获取到该类的类型
    @Inject(databaseConfig.KEY)
    private config: ConfigType<typeof databaseConfig>
  ) {}

  createTypeOrmOptions(): TypeOrmModuleOptions {
    return {
      type: "mysql",
      host: this.config.host,
      port: this.config.port,
      username: "root",
      password: "123456",
      database: "nest_test",
      entities: [User],
      synchronize: true,
      logging: ["error"],
    };
  }
}

十二、数据库使用(TypeORM)

1. ORM 是什么

ORM:对象关系映射,其主要作用是在编程中,把面向对象的概念跟数据库中的概念对应起来。举例:
定义一个对象,那就对应着一张表,这个对象的实例,就对应着表中的一条记录。

2. 在 nest 中使用 TypeORM(基础)

  • 安装
npm i @nestjs/typeorm typeorm mysql2
  • app.module.ts中使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule } from "@nestjs/typeorm";

@Module({
  imports: [
    UserModule,
    TypeOrmModule.forRoot({
      type: "mysql",
      host: "localost",
      port: 3306,
      username: "root",
      password: "123456",
      database: "nest_test",
      // 配置实体
      entities: [],
      // 同步本地的schema与数据库 => 初始化的时候去使用(开发阶段设置true)
      synchronize: true,
      // 日志等级
      logging: ["error"],
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

3. 在 nest 中使用 TypeORM(结合 env 进阶使用)

  • app.module.ts中使用
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    // 进阶写法 异步导入
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService], // 注入到useFactory中
      useFactory: (configService: ConfigService) =>
        ({
          type: "mysql",
          host: configService.get("DB_HOST"),
          port: 3306,
          username: "nest_test",
          password: "123456",
          database: "nest_test",
          entities: [],
          synchronize: true,
          logging: ["error"],
        } as TypeOrmModuleOptions),
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

4. 使用 TypeORM 创建多个实体

  • src/user目录下新建user.entity.ts文件
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

// 添加@Entity装饰器来表示实体
@Entity()
export class User {
  // 主键
  @PrimaryGeneratedColumn()
  id: number;

  // 列
  @Column({ unique: true }) // unique: true 创建唯一数据
  username: string;

  @Column()
  password: string;

  @Colum({
    default: false,
  })
  // mysql中没有布尔类型,会自动转为0/1
  isAdmin: boolean;

  // 创建时候的字段
  @CreateDateColum()
  createTime: Date;

  // 更新时候的字段
  @UpdateDateColum()
  updateTime: Date;
}
  • app.module.ts

配置TypeOrmModule.forRootAsyncentities

import { User } from "./user/user.entity";

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService], // 注入到useFactory中
  useFactory: (configService: ConfigService) =>
    ({
      type: "mysql",
      host: configService.get("DB_HOST"),
      port: 3306,
      username: "nest_test",
      password: "123456",
      database: "nest_test",
      // 配置
      entities: [User],
      synchronize: true,
      logging: ["error"],
    } as TypeOrmModuleOptions),
});

5. 一对一关系

需要有@JoinColumn(),建立表的关联

  • 新建profile.entity.ts文件
import {
  Column,
  Entity,
  JoinColumn,
  OneToOne,
  PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./user.entity";

@Entity()
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  gender: number;

  @Column()
  photo: string;

  @Column()
  address: string;

  // 使用函数是为了方便调用
  @OneToOne(() => User)
  // JoinColumn告诉TypeORM,在哪个表格去创建关联的字段
  // 默认生成userId字段,通过user表的主键和表名进行拼接。通过name,可以自定义关联字段名
  @JoinColumn({ name: "uid" })
  // 注入到user字段,返回的是user实体
  user: User;
}
  • user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;

  // 将profile注入到user实体上
  @OneToOne(() => Profile, (profile) => profile.user)
  profile: Profile;
}
  • app.module.ts中进行导入
import { Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { User } from "./user/user.entity";
import { Profile } from "./user/profile.entity";
import { Roles } from "./roles/roles.entity";
import { Logs } from "./logs/logs.entity";

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) =>
        ({
          type: "mysql",
          host: configService.get("DB_HOST"),
          port: 3306,
          username: "nest_test",
          password: "123456",
          database: "nest_test",
          // 导入的entity
          entities: [User, Profile, Roles, Logs],
          synchronize: true,
          logging: ["error"],
        } as TypeOrmModuleOptions),
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

6. 一对多关系

需要有@JoinColumn(),建立表的关联

  • 需要在app.module.ts中的entities中导入(省略)

  • user.entity.ts

import { Logs } from "src/logs/logs.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;

  // 一对多
  // 建立与数据库之间的关联关系,将查询出来的数据塞入logs属性中
  @OneToMany(() => Logs, (logs) => logs.user)
  logs: Logs[];
}
  • logs.entity.ts
import { User } from "src/user/user.entity";
import { Column, Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Logs {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  path: string;

  @Column()
  method: string;

  @Column()
  data: string;

  @Column()
  result: number;

  // 多对一
  @ManyToOne(() => User, (user) => user.logs) // 参数2:参数是user实体
  @JoinColumn()
  user: User;
}

7. 多对多关系

使用@JoinTable建立中间表

  • 需要在app.module.ts中的entities中导入(省略)

  • user.entity.ts

import { Logs } from "src/logs/logs.entity";
import {
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  OneToMany,
  PrimaryGeneratedColumn,
} from "typeorm";
import { Roles } from "../roles/roles.entity";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;

  @ManyToMany(() => Roles, (roles) => roles.users)
  @JoinTable({
    name: "user_roles",
    joinColumns: [{ name: "user_id" }],
    inverseJoinColumns: [{ name: "role_id" }],
  })
  roles: Roles[];
}
  • roles.entity.ts
import { User } from "src/user/user.entity";
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @ManyToMany(() => User, (user) => user.roles)
  users: User[];
}

8. 旧项目已有数据库,用 TypeORM 生成对接

typeorm-model-generator生成器

  • 安装
npm i -D typeorm-model-generator
  • package.json中新增脚本
# typeorm-model-generator -h 地址 -p 端口号 -d 数据库名 -u 用户名 -x 密码 -e 数据库类型 -o 输出路径
typeorm-model-generator -h 127.0.0.1 -p 3306 -d nest_test -u root -x 123456 -e mysql -o .

9. CRUD 操作

9.1 基本使用

  • user.module.ts导入
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";

@Module({
  // 导入
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
  • user.service.ts中使用
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { Repository } from "typeorm";

@Injectable()
export class UserService {
  // 依赖注入
  constructor(
    // readonly可加可不加
    @InjectRepository(User) private readonly userRepository: Repository<User>
  ) {}

  async getUser() {
    let res = await this.userRepository.find();
    return {
      code: 200,
      msg: "success",
      data: res,
    };
  }
}

9.2 查找

  1. find()
findAll() {
  return this.userRepository.find()
}
  1. findAll()
find(username: string) {
  return this.userRepository.findOne({ where: { username } })
}

9.3 新增

create():需要传入 User 实体

// 创建User实体
const user = new User();
user.username = "wifi";
user.password = "123456";
this.userRepository.save(userTmp);
create(user: User) {
  const userTmp = this.userRepository.create(user)
  return this.userRepository.save(userTmp)
}

9.4 更新

update()

update(id: number, user: Partial<User>) {
  return this.userRepository.update(id, user)
}

9.5 删除

delete()

delete(id: number) {
  return this.userRepository.delete(id)
}

9.6 联合查询:一对一

根据上面的 5 点(一对一关系),进行对表的一对一联合查询

findProfile(id: number) {
  return this.userRepository.findOne({
      where: {
          id
      },
      relations: {
          profile: true
      }
  })
}

9.6 联合查询:一对多

findUserLogs(id: number) {
  const user = this.userRepository.findOne({ where: { id } })
  return this.logsRepository.find({
      where: {
          user // logs返回的是user实体,具体查看上面第6点一对多
      },
      relations: {
          user: true // 返回关联的字段信息
      }
  })
}

9.9 QueryBuilder 高级查询

聚合,分页查询

findLogsByGroup(id: number) {
  // SELECT logs.result, COUNT(logs.result) from logs, user WHERE user.id = logs.userId AND user.id = 2 GROUP BY logs.result;
  return this.logsRepository.createQueryBuilder('logs')
    .select('logs.result') // logs.result是根据this.logsRepository.createQueryBuilder('logs')的logs来的
    .addSelect('COUNT(logs.result)', 'count')
    .leftJoinAndSelect('logs.user', 'user') // user对查询出来的字段取别名
    .where('user.id = :id', { id }) // :id这种写法是为了防止sql注入
    .groupBy('logs.result')
    .orderBy('result', 'DESC') // 对result字段进行倒序排序
    .addOrderby('count', 'DESC')
    .offset(2)
    .limit(10)
    .getRawMany();
}

9.10 TypeORM 使用原生 sql 查询

通过query()

this.userRepository.query("select * from logs");

9.11 remove()delete()的区别

建议使用:remove()

  1. remove可以一次性删除单个或者多个实例,并且remove可以触发BeforeRemoveAfterRemove钩子;
await repository.remove(user); // user实体
await repository.remove([user1, user2, user3]);

钩子函数写在实体类上面:

user.entity.ts中:

import { Logs } from "src/logs/logs.entity";
import { AfterInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Roles } from "../roles/roles.entity";
import { Profile } from "./profile.entity";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;

  // AfterRemove 钩子函数
  @AfterInsert()
  afterInsert() {
    console.log("afterInsert", this.id, this.username); // 可以拿到数据
  }
}
  1. delete可以一次性删除单个或多个 id 实例,或者给定条件,delete()是硬删除
await repository.delete(1); // id

10. 数据库代码重构

10.1 写法 1:forRoot 同步写法

  1. 在项目根目录中创建ormconfig.ts
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { User } from "./src/user/user.entity";
import { Logs } from "./src/logs/logs.entity";
import { Roles } from "./src/roles/roles.entity";
import { Profile } from "./src/user/profile.entity";
import * as fs from "fs";
import * as dotenv from "dotenv";
import { DataSource, DataSourceOptions } from "typeorm";

// 通过环境变量读取不同的.env文件
const getEnv = (env: string): Record<string, unknown> => {
  if (fs.existsSync(env)) {
    // 判断文件路径是否存在
    return dotenv.parse(fs.readFileSync(env));
  }
  return {};
};
// 通过dotenv来解析不同的配置
const buiildConnectionOptions = () => {
  const defaultConfig = getEnv(".env");
  const envConfig = getEnv(`.env.${process.env.NODE_ENV || "development"}`);
  // 配置合并
  const config = { ...defaultConfig, ...envConfig };

  const entitiesDir =
    process.env.NODE_ENV === "development"
      ? [__dirname + "/**/*.entity.ts"]
      : [__dirname + "/**/*.entity{.js,.ts"];

  return {
    type: "mysql",
    host: config["DB_HOST"],
    port: config["DB_PORT"],
    username: "nest_test",
    password: "123456",
    database: "nest_test",
    // entities: [User, Profile, Roles, Logs],
    // 如果导入文件过多,依次导入会非常麻烦(windows下路径会有问题)
    entities: entitiesDir,
    synchronize: true,
    logging: ["error"],
  } as TypeOrmModuleOptions;
};

export const connectionParams = buiildConnectionOptions();

export default new DataSource({
  ...connectionParams,
  migrations: ["src/migrations/**"],
  subscribers: [],
} as DataSourceOptions);
  1. app.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { connectionParams } from "ormconfig";
import { UserModule } from "./user/user.module";

@Module({
  imports: [UserModule, TypeOrmModule.forRoot(connectionParams)],
  controllers: [],
  providers: [],
})
export class AppModule {}

根据上面步骤,就将app.module.ts中的数据库配置文件提取到ormconfig.ts中了。

10.2 写法 2:forRootAsync 异步写法

  1. 新建TypeOrmConfigService.ts文件
import { Injectable } from "@nestjs/common";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";
import { User } from "./user/user.entity";

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  createTypeOrmOptions(): TypeOrmModuleOptions {
    return {
      type: "mysql",
      host: "localost",
      port: 3306,
      username: "root",
      password: "123456",
      database: "nest_test",
      entities: [User],
      synchronize: true,
      logging: ["error"],
    };
  }
}
  1. app.module.ts中使用
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserModule } from "./user/user.module";
import { TypeOrmConfigService } from "./TypeOrmConfigService";
import { User } from "./user/user.entity";

@Module({
  imports: [
    UserModule,
    TypeOrmModule.forRootAsync({
      // useClass: 使用该类
      useClass: TypeOrmConfigService,
    }),
    // 这里一定要在 imports 中导入使用的实体类,否则 nestjs 框架无法通过 new 进行实例化相应类型的实例对象
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

十三、JWT 登陆鉴权

1. 安装

npm install --save @nestjs/jwt passport-jwt

npm i @nestjs/passport

npm i @types/passport-jwt -D

2. auth 模块(在 1 模块使用 2 模块)

  1. 新建文件
  • 新建auth目录

  • 新建auth/auth.module.ts文件

  • 新建auth/auth.controller.ts文件

  • 新建auth/auth.service.ts文件

  1. app.module.ts中导入auth模块

  2. auth模块使用user模块的service

  • user.module.ts导出
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";

@Module({
  controllers: [UserController],
  providers: [UserService],
  // 要在auth模块使用,需要将userService导出
  exports: [UserService],
})
export class UserModule {}
  • auth.module.ts导入userService
// auth.module.ts
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UserService } from "src/user/user.service";

@Module({
  controllers: [AuthController],
  providers: [AuthService, UserService],
})
export class AuthModule {}
  • auth.controller.ts
import {
  Body,
  Controller,
  HttpException,
  HttpStatus,
  Post,
} from "@nestjs/common";
import { AuthService } from "./auth.service";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  signin(@Body() dto: { username: string; password: string }) {
    const { username, password } = dto;
    if (!username || !password) {
      throw new HttpException(
        "用户名或密码不能为空",
        HttpStatus.EXPECTATION_FAILED
      );
    }
    return this.authService.signin(username, password);
  }

  @Post("signup")
  signup(@Body() dto: { username: string; password: string }) {
    const { username, password } = dto;
    return this.authService.signup(username, password);
  }
}
  • auth.service.ts中使用
import { Injectable } from "@nestjs/common";
import { UserService } from "../user/user.service";

// service一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
  // 使用user
  constructor(private userService: UserService) {}

  signin(username: string, password: string) {
    let res = this.userService.getUser();
    return {
      code: 200,
      data: {
        username,
        password,
      },
      res,
    };
  }

  signup(username: string, password: string) {
    /**
     * 用户信息的ts类型
     * type User = {
     *      username: string,
     *      password: string,
     *      age: number;
     *      gender: string;
     * }
     * 但是在登陆注册时,不需要传递这么多参数,但是ts会报错,可以使用ts的Partial类型,将User类型转换为Partial类型,即所有属性都是可选的
     */
    return {
      code: 200,
      data: {
        username,
        password,
      },
    };
  }
}

3. JWT 集成(正式)

jwt集成

3.1 生成 token

登录接口返回 token:

流程:sign 接口 -> service -> jwtService(jwtModule 内置的)

  1. auth.module.ts中:
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { UserService } from "../user/user.service";

@Module({
  imports: [
    // 注册秘钥:写法1
    // JwtModule.register({
    //   secret: 'c6beBF1C-6Df4-4e1E-22B6-BfbccB8d8FDc', // 设置秘钥
    //   signOptions: { expiresIn: '60s' }, // 过期时间
    // })
    // 注册秘钥:写法2(异步读取环境变量)
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        return {
          secret: configService.get<string>("SECRET"), // 设置秘钥
          signOptions: { expiresIn: "1d" }, // 过期时间:1天
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, UserService],
})
export class AuthModule {}
  1. auth.service.ts中书写业务逻辑

this.jwt.sign() 生成 token 信息

this.jwt.verify() 校验 token 信息

import { Injectable, UnauthorizedException } from "@nestjs/common";
import { UserService } from "../user/user.service";
import { JwtService } from "@nestjs/jwt";

// 一定要加@Injectable,这个DI系统才会把他添加为一个实例
@Injectable()
export class AuthService {
  constructor(private userService: UserService, private jwt: JwtService) {}

  async signin(username: string, password: string) {
    // 通过userService的getUser方法获取用户的用户名和密码
    let user = this.userService.getUser(username);
    // 对用户密码进行比对,密码正确执行下面if判断
    if (user && user.password === password) {
      // 验证用户密码是否正确,正确后生成token
      // this.jwt.sign() 生成token信息
      // this.jwt.verify() 校验token信息
      return await this.jwt.signAsync(
        {
          username: user.username,
          id: "真实的用户id",
        },
        {
          expiresIn: "1d", // 局部设置过期时间
        }
      );
    }
    // 密码不正确,返回身份信息校验失败
    throw new UnauthorizedException();
  }
}
  1. sign 接口,在auth.controller.ts中:
import { Body, Controller, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
// 校验参数,校验失败响应错误信息
import { SigninUserDto } from "./dto/signin-user.dto";

@Controller("auth")
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post("signin")
  async signin(@Body() dto: SigninUserDto) {
    const { username, password } = dto;
    // 密码正确,返回生成的token
    const token = await this.authService.signin(username, password); // 登录成功,拿到用户token信息
    return {
      access_token: token,
    };
  }
}
  1. 发送请求
  • curl为:
curl --location --request POST 'http://127.0.0.1:3000/api/v1/auth/signin' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
    "username": "xiaotianwifi",
    "password": "123456"
}'
  • 返回的结果为:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyNDA3NzMsImV4cCI6MTcyMTMyNzE3M30.S8mGaeADX3kxYWqkovv_s3SWmECiB6GRo-tlk2psUco"
}

这样就实现了 token 的颁发,前端在需要登录的接口,在HeadersAuthorization中携带 token,通过下面讲的校验token即可对用户身份进行校验。

3.2 校验 token

  1. jwt.strategy.ts

通过要求在请求中存在有效的 JWT 来保护端点。Passport 也可以帮助我们实现这一点。它提供了passport-jwt策略,用于使用 JSON Web Token 保护 RESTful 端点。首先,在 auth 文件夹中创建一个名为 jwt.strategy.ts 的文件,并添加以下代码:

import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(protected configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>("SECRET"),
    });
  }

  // 对token进行校验
  async validate(payload: any) {
    // 将数据加到request.user上
    return { userId: payload.sub, username: payload.username };
  }
}
  1. auth.module.ts
  • 导入 Passport

  • 将 JwtStrategy 注入到 DI 系统中,供 passportModule 使用

import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtStrategy } from "./jwt.strategy";
import { UserService } from "../user/user.service";

@Module({
  imports: [
    // 导入Passport
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        return {
          secret: configService.get<string>("SECRET"),
          signOptions: { expiresIn: "1d" },
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    // 将JwtStrategy注入到DI系统中,供passportModule使用
    JwtStrategy,
    UserService,
  ],
})
export class AuthModule {}
  1. user.controller.ts

例如:创建用户接口,需要判断用户是否有 token

@UseGuards(AuthGuard('jwt'))

守卫AuthGuard的校验逻辑是在jwt.strategy.tsvalidate中定义的

import { Body, Controller, Post, Req, UseGuards } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { AuthGuard } from "@nestjs/passport";

@Controller("user")
// 守卫AuthGuard的校验逻辑是在jwt.strategy.ts的validate中定义的
// 对所有user模块接口都校验
@UseGuards(AuthGuard("jwt"))
export class UserController {
  constructor(private userService: UserService) {}

  @Post("create")
  // 只校验create接口
  @UseGuards(AuthGuard("jwt"))
  createUser(@Body() dto: CreateUserDto, @Req() req) {
    // @Req() req这里的req.user是通过AuthGuard('jwt')中的validate方法返回的
    // PassportModule来添加的(自动添加的)
    console.log(req.user);

    const { username, password } = dto;
    return this.userService.createUser(username, password);
  }
}
  1. 发送请求
  • curl为:
curl --location --request POST 'http://127.0.0.1:3000/api/v1/user/create' \
--header 'Authorization: Bearer yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyMzY4NTIsImV4cCI6MTcyMTMyMzI1Mn0.lUttPVP8Q-h11zXpIIH5MZ9pS5a-mmqDXAeXCH5wR5E' \
--header 'Content-Type: application/json' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:3000' \
--header 'Connection: keep-alive' \
--data-raw '{
    "username": "xiaotianwifi",
    "password": "123456"
}'

请求头携带的 token 信息

--header 'Authorization: Bearer yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InhpYW90aWFud2lmaSIsImlkIjoi55yf5a6e55qE55So5oi3aWQiLCJpYXQiOjE3MjEyMzY4NTIsImV4cCI6MTcyMTMyMzI1Mn0.lUttPVP8Q-h11zXpIIH5MZ9pS5a-mmqDXAeXCH5wR5E
  • 响应结果为:(正确 token)
{
  "msg": "success",
  "username": "xiaotianwifi",
  "password": "123456"
}
  • 响应结果为:(错误 token)
{
  "message": "Unauthorized",
  "statusCode": 401
}

3.3 校验 JWT 代码优化

新建guards目录,新建jwt.guard.ts

import { AuthGuard } from "@nestjs/passport";

export class JwtGuard extends AuthGuard("jwt") {
  constructor() {
    // 调用super执行AuthGuard
    super();
  }
}

在使用@UseGuards(AuthGuard('jwt'))的地方可以替换成@UseGuards(JwtGuard)

Post('create')
// 原
// @UseGuards(AuthGuard('jwt'))
// 优化后
@UseGuards(JwtGuard)
createUser(@Body() dto: CreateUserDto, @Req() req) {
    const { username, password } = dto;
    return this.userService.createUser(username, password);
}

十四、授权

1. 授权是什么

授权(Authorization)也叫权限控制,指的是确定用户能够执行什么操作的过程。例如,管理员用户被允许创建、编辑和删除帖子。非管理员用户只能被授权阅读帖子。

授权与认证是正交且独立的。然而,授权需要认证机制。

处理授权有许多不同的方法和策略。采用的方法取决于项目的特定应用需求。下面介绍基于RBACCASL实现。

区别:

RBAC:基于角色的权限控制

CASL:基于策略的权限控制

2. 基于RBAC实现-权限控制

2.1 什么是RBAC

RBAC:基于角色的访问控制(RBAC),是围绕角色和权限定义的策略中立的访问控制机制。

2.2 实现步骤

  1. src目录下新建enum目录,在enum目录新建role.enum.ts文件

该文件用于设置角色权限的常量

export enum Role {
    User = 'user',
    Admin = 'admin',
}
  1. 新建decorators目录,在decorators目录下新建roles.decorator.ts文件

该文件用于设置路由的注解信息

SetMetadata设置注解信息

import { SetMetadata } from '@nestjs/common';
import { Role } from '../enum/role.enum';

export const ROLES_KEY = 'roles'; // ROLES_KEY:自定义
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); // SetMetadata设置注解信息
  1. 新建guards目录,在guards目录下新建role.guard.ts文件

该文件用于创建自定义角色路由守卫,用来判断用户是否有权限访问该接口

getAllAndOverride:读取路由上的metadata

getAllAndMerge:合并两者

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enum/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserService } from '../user/user.service';

@Injectable()
export class RoleGuard implements CanActivate {
  constructor(
    // 去访问加在controller上的注解信息的
    private reflector: Reflector,
    private userService: UserService
  ) {}

  async canActivate(
    context: ExecutionContext,
  ): Promise<boolean> {
    // 思路:jwt -> userId -> user -> roles
    // jwt部分需要知道useGuards的执行顺序
    /**
     * getAllAndOverride:读取路由上的metadata
     * getAllAndMerge:合并两者
     */
    const requireRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(), // 从路由上读
      context.getClass() // 从controller上读
    ])
    console.log(requireRoles); // 返回的是一个数组 [ 'user' ]
    if (!requireRoles) {
      return true
    }
    const req = context.switchToHttp().getRequest()
    const user = this.userService.getUser(req.user.username)
    /**
     * 获取到user实体上的,判断是否有权限,有则返回true,没有返回false
     */
    // user.role.map......
    return true
  }
}
  1. controller中进行拦截限制,在role.controller.ts

注:需要知道@UseGuards()的执行顺序

import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from '../decorators/roles.decorator';
import { Role } from '../enum/role.enum';
import { RoleGuard } from '../guards/role.guard';
import { JwtGuard } from '../guards/jwt.guard';

@Controller('role')
@Roles(Role.Admin)  // 添加注解信息
@UseGuards(JwtGuard, RoleGuard) // 需要知道@UseGuards()的执行顺序,否则拿不到req.user的信息,在JWT部分有讲
export class RoleController {
  @Get()
  @Roles(Role.User)
  findAll() {
    return 'ok';
  }
}

3. 基于CASL实现

3.1 什么是CASL

CASL 是一个可用于浏览器和服务器的授权库,它限制了特定客户端被允许访问的资源。它被设计成可以逐步采用,可以在基于简单声明的授权和基于完整主题和属性的授权之间轻松扩展。

官网地址:https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types

3.2 安装

安装 @casl/ability

npm i @casl/ability

3.3 casl库的基本使用

通过语义化的cancannot就可以完成权限控制

// import { defineAbility } from '@casl/ability'
const { defineAbility } = require('@casl/ability')

const user = {
    id: 1,
    isAdmin: false
}

class Post {
    constructor(attrs) {
        Object.assign(this, attrs)
    }
}

const ability = defineAbility((can, cannot) => {
    can('read', 'all') // 'read'是自定义的,只要跟后面匹配就行

    /**
     * 文章没有发布,isPublished为false,可以更新;
     * 文章发布,isPublished为true,不允许更新,只有管理员可以
     * can('update', 'Post', { isPublished: false, author: user.id })
     */
    // ['content']:对Post某些字段做一些限制,表示用户只能对content字段进行修改,在下面flag的时候就可以添加判断条件
    can('update', 'Post', ['content'], { isPublished: false, author: user.id })
    cannot('delete', 'Post')

    if (user.isAdmin) {
        // 设置管理员可以更新和删除
        can('update', 'Post')
        can('delete', 'Post')
    }
})

const somePost = new Post({ author: 1, isPublished: false })

// const flag = ability.can('update', somePost) // true
// 给flag添加判断条件
const flag = ability.can('update', somePost, 'id') // false

console.log(flag);

3.4 casl与nestjs的集成

  1. 为了说明 CASL 的机制,我们将定义两个实体类:UserArticle
class User {
  id: number;
  isAdmin: boolean;
}

User 类由两个属性组成,id 是唯一的用户标识,isAdmin 表示用户是否具有管理员权限。

class Article {
  id: number;
  isPublished: boolean;
  authorId: number;
}

Article 类有 3 个属性,分别为 idisPublishedauthorIdid 为文章唯一标识,isPublished 为文章是否已发表,authorId 为撰写文章的用户 ID。

  1. 例子需求分析
  • 管理员可以管理(创建/读取/更新/删除)所有实体
  • 用户对所有内容都具有只读访问权限
  • 用户可以更新他们的文章 (article.authorId === userId)
  • 已发布的文章无法删除 (article.isPublished === true)
  1. src目录下新建enum目录,新建action.enum.ts

可以从创建一个 Action 枚举开始,该枚举表示用户可以对实体执行的所有可能操作:

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

⚠️注意:manage 是 CASL 中的一个特殊关键字,代表 “any” 动作。

  1. auth目录下新建CaslAbilityFactory.ts,主要用来创建ability对象的

CaslAbilityFactory.ts也可以叫casl.service.ts,根据文件名可以知道是需要在moduleprovidersexports的。

可以在 CaslAbilityFactory 上定义 createForUser() 方法。此方法将为给定用户创建 Ability 对象:

import { Injectable } from "@nestjs/common";
import { Article, User } from "./auth.dto";
import { AbilityBuilder, ExtractSubjectType, InferSubjects, createMongoAbility } from "@casl/ability";
import { Action } from "../enum/action.enum";

type Subjects = InferSubjects<typeof Article | typeof User> | 'all';

@Injectable()
export class CaslAbilityService {
    createForUser(user: User) {
        const { can, cannot, build } = new AbilityBuilder(createMongoAbility)

        if (user.isAdmin) {
            can(Action.Manage, 'all')
        } else {
            can(Action.Read, 'all')
        }

        can(Action.Update, Article, { authorId: user.id })
        cannot(Action.Delete, User, { isAdmin: true })

        return build({
            detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>
        })
    }
}

all 是 CASL 中的一个特殊关键字,代表 “任何科目”。

使用 AbilityBuilder 类创建了 Ability 实例。cancannot 接受相同的参数但具有不同的含义,can 允许对指定的主题执行操作,而 cannot 禁止。两者都可以接受最多 4 个参数。

  1. src/guards目录下新建casl.guard.ts文件,主要用来创建自定义管道的

如果没有该操作权限,则被拦截,返回false

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { CaslAbilityService } from '../auth/casl-ability.service';
import { Article, User } from "../auth/auth.dto"
import { Action } from "../enum/action.enum";

@Injectable()
export class CaslGuard implements CanActivate {
    constructor(
        private caslAbilityService: CaslAbilityService
    ) { }

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const user = new User();
        user.isAdmin = false

        let flag = true
        const ability = this.caslAbilityService.createForUser(user)
        
        // 验证
        flag = ability.can(Action.Read, Article); // true
        // flag = ability.can(Action.Delete, Article); // false
        // flag = ability.can(Action.Create, Article); // false

        return flag
    }
}

验证:

const user = new User();
user.isAdmin = false;

const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Read, Article); // true
ability.can(Action.Delete, Article); // false
ability.can(Action.Create, Article); // false
const user = new User();
user.id = 1;

const article = new Article();
article.authorId = user.id;

const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Update, article); // true

article.authorId = 2;
ability.can(Action.Update, article); // false
  1. user.controller.ts中使用

主要的操作判断还是在5的casl.guard.ts管道中

import { Controller, Get, UseGuards } from '@nestjs/common';
import { CaslGuard } from '../guards/casl.guard';

@Controller('user')
@UseGuards(CaslGuard)
export class UserController {
    @Get()
    getUser() {
        return 'success'
    }
}

3.5 高级:使用自定义装饰器(让casl更灵活)

在3.4的第4点CaslAbilityFactory.ts中的cancannot是在内部实现的,其实可以通过自定义装饰器来动态控制需要拦截的参数

  1. 新建casl.service.ts文件
import { Injectable } from "@nestjs/common";
import { Article, User } from "./auth.dto";
import { AbilityBuilder, ExtractSubjectType, InferSubjects, createMongoAbility } from "@casl/ability";
import { Action } from "../enum/action.enum";

type Subjects = InferSubjects<typeof Article | typeof User> | 'all';

@Injectable()
export class CaslAbilityService {
    createForUser(user: User) {
        const { can, cannot, build } = new AbilityBuilder(createMongoAbility)

        // can('manage', 'all') // manage all是内部保留字段
        // 这部分的数据,要通过数据库查询到用户的操作权限,进而做出判断
        can('read', User)
        cannot('update', User)

        const ability = build({
            detectSubjectType: (item) => item.constructor as ExtractSubjectType<Subjects>
        })
        
        return ability
    }
}

通过can/cannot方法来配置权限,使用控制反转,将如何使用交给用户

ability.can(操作权限, 实体, [字段])
ability.can('update', User, 'id') // id:具体根据User中的某个字段来限制
  1. 新建两个枚举文件
  • 新建casl.enum.ts文件

该文件用于根据不同自定义装饰器,使用SetMetadata设置不同的meta字段

export enum CheckPoliciesKey {
    Handler = 'CheckPolicies_Handler',
    Can = 'CheckPolicies_Can',
    Cannot = 'CheckPolicies_Cannot'
}
  • 新建action.enum.ts文件

该文件用于枚举出不同权限用户可以做出的操作策略

export enum Action {
    Manage = 'manage',
    Create = 'create',
    Read = 'read',
    Update = 'update',
    Delete = 'delete'
}
  1. 新建casl.decorator.ts文件

思路:实现:Guards -> routes meta -> @CheckPolicies @Can @Cannot

@CheckPolicies:接收一个函数,传递一个ability,返回一个布尔值

@Can/@Cannot:接收两/三个参数:ActionSubjectCondition

该参数通过Controller使用自定义装饰器时传参

import { AnyMongoAbility, InferSubjects } from '@casl/ability';
import { SetMetadata } from '@nestjs/common';
import { Action } from '../enum/action.enum';
import { CheckPoliciesKey } from '../enum/casl.enum';

type PolicyHandleCallback = (ability: AnyMongoAbility) => boolean;
// @CheckPolicies:接收一个函数,传递一个ability,返回一个布尔值
export const CheckPolicies = (...handlers: PolicyHandleCallback[]) =>
    SetMetadata(CheckPoliciesKey.Handler, handlers);

// export const Can = (action: Action, subject: InferSubjects<any>, conditions?: any) => {
//     console.log(action, subject, conditions);
//     /**
//      * 形参ability:通过SetMetadata,传递meta信息为一个函数,在casl.guard.ts文件中调用该函数并传递ability参数
//      * 实参ability:在自定义管道中传参
//      */
//     // ability.can(Action.Read, 'all')
//     let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions)
//     return SetMetadata(CheckPoliciesKey.Can, fn)
// }

// @Can:接收两/三个参数:Action、Subject、Condition
export const Can = (
    action: Action,
    subject: InferSubjects<any>,
    conditions?: any,
) =>
    SetMetadata(CheckPoliciesKey.Can, (ability: AnyMongoAbility) =>
        ability.can(action, subject, conditions),
    );

// @Cannot:接收两/三个参数:Action、Subject、Condition
export const Cannot = (
    action: Action,
    subject: InferSubjects<any>,
    conditions?: any,
) =>
    SetMetadata(CheckPoliciesKey.Cannot, (ability: AnyMongoAbility) =>
        ability.can(action, subject, conditions),
    );
  1. 形参ability:通过SetMetadata,传递meta信息为一个函数,在casl.guard.ts文件中调用该函数并传递ability参数(在3、casl.decorator.ts中)
  2. 实参ability:在自定义管道中传参(在4、casl.guard.ts文件中)

该文件用来创建自定义装饰器,供controller使用,传递的参数会加到SetMetadata

let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions)
return SetMetadata(CheckPoliciesKey.Can, fn)
  1. 新建casl.guard.ts文件

该文件用来创建自定义管道

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"
import { Reflector } from '@nestjs/core'
import { CheckPoliciesKey } from "../enum/casl.enum"
import { CaslAbilityService } from '../auth/casl-ability.service';
import { Article, User } from "../auth/auth.dto"
import { Action } from "../enum/action.enum";
import { AnyMongoAbility } from "@casl/ability";

type PolicyHandleCallback = (ability: AnyMongoAbility) => boolean;

@Injectable()
export class CaslGuard implements CanActivate {
    constructor(
        private reflector: Reflector, // 用于读取meta字段
        private caslAbilityService: CaslAbilityService
    ) { }

    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 读取meta字段
        // @CheckPolicies
        const handlers = this.reflector.getAllAndOverride(CheckPoliciesKey.Handler, [
            context.getHandler(),
            context.getClass()
        ]) as Partial<PolicyHandleCallback | PolicyHandleCallback[]>

        // @Can
        const canHandlers: Partial<PolicyHandleCallback | PolicyHandleCallback[]> = this.reflector.getAllAndOverride(CheckPoliciesKey.Can, [
            context.getHandler(),
            context.getClass()
        ])

        // @Cannot
        const cannotHandlers = this.reflector.getAllAndOverride<Partial<PolicyHandleCallback | PolicyHandleCallback[]>>(CheckPoliciesKey.Cannot, [
            context.getHandler(),
            context.getClass()
        ])

        // 判断:如果用户未设置上述任何一个,则返回true
        if (!handlers && !canHandlers && !cannotHandlers) {
            return true
        }

        // 获取到ability,通过ability.can(Action.Read, Article)进行角色操作权限判断
        const ability = this.caslAbilityService.createForUser()
        let flag = true

        if (handlers) {
            if (handlers instanceof Array) {
                flag = flag && handlers.every(handler => {
                    /**
                     * 本质是通过 ability.can(Action.Read, Article); // true/false 来判断
                     * 但是这里使用到了控制反转,每个参数都是函数,函数在SetMetadata的第二个参数
                     */
                    return handler(ability)
                })
            } else if (typeof handlers === 'function') {
                flag = flag && handlers(ability)
            }
        }
        if (flag && canHandlers) {
            flag = flag && (Array.isArray(canHandlers) ? canHandlers : [canHandlers]).every(handler => handler(ability))
        }
        if (flag && cannotHandlers) {
            flag = flag && (Array.isArray(cannotHandlers) ? cannotHandlers : [cannotHandlers]).every(handler => handler(ability))
        }

        return flag
    }
}

⚠️注意:在使用CaslAbilityService时候,需要将CaslAbilityService从模块到处,在使用他的模块中进行导入

这里比较难理解的是:使用了控制反转,将ability.can()的使用,交给了自定义装饰器的传参。

handlerscanHandlerscannotHandlers三个都是函数/函数数组【fn】(在3、casl.decorator.ts文件中

let fn = (ability: AnyMongoAbility) => ability.can(action, subject, conditions)
return SetMetadata(CheckPoliciesKey.Can, fn)
  1. 使用

⚠️注意:在1、casl.service.ts中限制了user的操作:

这部分的数据,要通过数据库查询到用户的操作权限,进而做出判断

can('read', User)
cannot('update', User)
  • user.controller.ts中使用
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Can, CheckPolicies } from '../decorators/casl.decorator';
import { Action } from '../enum/action.enum';
import { CaslGuard } from '../guards/casl.guard';
import { User } from '../auth/auth.dto';

@Controller('user')
@UseGuards(CaslGuard)
@CheckPolicies((ability) => ability.can(Action.Read, User))
@Can(Action.Read, User) // 通过
export class UserController {
    @Get()
   	@Can(Action.Update, User) // 不通过
    getUser() {
        return 'success'
    }
}
  • 结果:

当不使用@Can(Action.Update, User)时,会响应success

当使用后,则响应:

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

十五、日志收集

1. 使用 NestJS 内置的日志模块

内置Logger实例

1.1 在main.ts全局日志

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { Logger } from "@nestjs/common";

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    // logger: false // 关闭所有日志
    logger: ["error", "warn"], // 只打印错误和警告日志
  });
  app.setGlobalPrefix("/api/v1");
  const port = 3000;
  await app.listen(port);
  // 打印日志
  const logger = new Logger();
  logger.log(`服务允许在${port}端口`);
  logger.warn(`服务允许在${port}端口`);
  logger.error(`服务允许在${port}端口`);
}
bootstrap();
[Nest] 9232  - 2024/07/15 22:42:00     LOG 服务允许在3000端口 # 颜色为绿色
[Nest] 9232  - 2024/07/15 22:42:00    WARN 服务允许在3000端口 # 颜色为橙色
[Nest] 9232  - 2024/07/15 22:42:00   ERROR 服务允许在3000端口 # 颜色为红色

1.2 在user.controller.ts

import { Controller, Get, Logger } from "@nestjs/common";
import { UserService } from "./user.service";

@Controller("user")
export class UserController {
  private logger = new Logger(UserController.name); // 区分模块名称

  constructor(private userService: UserService) {
    this.logger.log("UserController Init");
  }

  @Get("user")
  findAll(): Object {
    this.logger.log(`请求findAll成功`);
    return this.userService.findAll();
  }
}
  • UserController初始化好后,会执行:
private logger = new Logger(UserController.name) // 区分模块名称
constructor(
    private userService: UserService
) {
    this.logger.log('UserController Init')
}
# LOG [UserController] 这个里的UserController就是new Logger(UserController.name)的UserController.name
[Nest] 9232  - 2024/07/15 22:42:00     LOG [UserController] UserController Init
  • 接口 log 日志
@Get('user')
findAll(): Object {
    this.logger.log(`请求findAll成功`)
    return this.userService.findAll()
}
[Nest] 9232  - 2024/07/15 22:54:03     LOG [UserController] 请求findAll成功

2. 集成第三方日志模块-Pino

2.1 安装

npm i nestjs-pino

2.2 使用

  1. user.module.ts中导入
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { LoggerModule } from "nestjs-pino";

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    // 导入pino日志模块
    LoggerModule.forRoot(),
  ],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}
  1. user.controller.ts使用
import { Controller, Get } from "@nestjs/common";
import { UserService } from "./user.service";
import { Logger } from "nestjs-pino";

@Controller("user")
export class UserController {
  constructor(private userService: UserService, private logger: Logger) {
    this.logger.log("UserService Init");
  }

  @Get("user")
  findAll(): Object {
    this.logger.log("findAll接口请求成功");
    return this.userService.findAll();
  }
}
{"level":30,"time":1721055802833,"pid":34300,"hostname":"WKQT253","msg":"UserService Init"}
  • this.logger.log(‘findAll 接口请求成功’)
{"level":30,"time":1721056214051,"pid":9076,"hostname":"WKQT253","req":{"id":1,"method":"GET","url":"/api/v1/user/user","query":{},"params":{"0":"user/user"},"headers":{"host":"localhost:3000","connection":"keep-alive","cache-control":"max-age=0","sec-ch-ua":"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Windows\"","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br, zstd","accept-language":"zh-CN,zh;q=0.9","if-none-match":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""},"remoteAddress":"::1","remotePort":58082},"msg":"findAll接口请求成功"}
# 默认在接口请求的时候会打印一次
{"level":30,"time":1721056214078,"pid":9076,"hostname":"WKQT253","req":{"id":1,"method":"GET","url":"/api/v1/user/user","query":{},"params":{"0":"user/user"},"headers":{"host":"localhost:3000","connection":"keep-alive","cache-control":"max-age=0","sec-ch-ua":"\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"Windows\"","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br, zstd","accept-language":"zh-CN,zh;q=0.9","if-none-match":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""},"remoteAddress":"::1","remotePort":58082},"res":{"statusCode":304,"headers":{"x-powered-by":"Express","etag":"W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""}},"responseTime":28,"msg":"request completed"}

2.3 pino-pretty 解决日志“丑”的方案

pino终端打印的日志不便于查看,使用pino-pretty中间件解决

  1. 安装
npm i pino-pretty
  1. 使用:在user.module.ts
imports: [
  LoggerModule.forRoot({
    pinoHttp: {
      transport: {
        // 无需用import导入,只用安装即可
        target: 'pino-pretty',
        options: {
          colorize: true
        }
      }
    }
  })
],
  • this.logger.log(‘UserService Init’)
[23:17:37.516] INFO (37496): UserService Init
  • 请求接口时
[23:24:59.120] INFO (31880): request completed
    req: {
      "id": 1,
      "method": "GET",
      "url": "/api/v1/user/user",
      "query": {},
      "params": {
        "0": "user/user"
      },
      "headers": {
        "host": "localhost:3000",
        "connection": "keep-alive",
        "cache-control": "max-age=0",
        "sec-ch-ua": "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"Windows\"",
        "upgrade-insecure-requests": "1",
        "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
        "sec-fetch-site": "none",
        "sec-fetch-mode": "navigate",
        "sec-fetch-user": "?1",
        "sec-fetch-dest": "document",
        "accept-encoding": "gzip, deflate, br, zstd",
        "accept-language": "zh-CN,zh;q=0.9",
        "if-none-match": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
      },
      "remoteAddress": "::1",
      "remotePort": 60401
    }
    res: {
      "statusCode": 304,
      "headers": {
        "x-powered-by": "Express",
        "etag": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
      }
    }
    responseTime: 29

2.4 pino-roll 用于生产环境(滚动日志)

pino-roll可以将日志输出到文件

  1. 安装
npm i pino-roll
  1. 使用:在user.module.ts
import { join } from "path";

LoggerModule.forRoot({
  pinoHttp: {
    transport: {
      // targets设置多个中间件
      targets: [
        {
          level: "info",
          target: "pino-pretty",
          options: {
            colorize: true,
          },
        },
        {
          level: "info",
          target: "pino-roll",
          options: {
            file: join("log", "log.txt"),
            frequency: "daily",
            size: "10m",
            mkdir: true,
          },
        },
      ],
    },
  },
});

2.5 生产环境,开发环境全局配置

app.module.ts

LoggerModule.forRoot({
  pinoHttp: {
    transport: {
      targets: [
        process.env.NODE_ENV === "development"
          ? {
              // 安装pino-pretty
              level: "info",
              target: "pino-pretty",
              options: {
                colorize: true,
              },
            }
          : {
              // 安装pino-roll
              level: "info",
              target: "pino-roll",
              options: {
                file: join("log", "log.txt"),
                frequency: "daily",
                size: "10m",
                mkdir: true,
              },
            },
      ],
    },
  },
});

3. 集成第三方日志模块-winston

3.1 安装

npm i nest-winston winston

3.2 使用

  1. main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
// 导入包
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";

async function bootstrap() {
  const instance = createLogger({
    transports: [
      new winston.transports.Console({
        format: winston.format.combine(
          winston.format.timestamp(), // 日志时间
          utilities.format.nestLike()
        ),
      }),
    ],
  });

  const app = await NestFactory.create(AppModule, {
    // 重构nest的logger实例
    logger: WinstonModule.createLogger({
      instance: instance,
    }),
  });
  app.setGlobalPrefix("/api/v1");
  const port = 3000;
  await app.listen(port);
}
bootstrap();
  1. app.module.ts

如果要全局注册使用 logger,需要在app.module.ts中使用@Global()注解,使app模块变成全局模块,进行全局注册,使用exports将 logger 导出使用

import { Global, Logger, Module } from "@nestjs/common";
import { UserModule } from "./user/user.module";

@Global()
@Module({
  imports: [UserModule],
  controllers: [],
  // 从@nestjs/common进行导入。因为在main.ts中重构官方的logger实例
  // 全局提供logger
  providers: [Logger],
  exports: [Logger],
})
export class AppModule {}
  1. user.controller.ts进行使用
import { Controller, Get, Inject, Logger, LoggerService } from "@nestjs/common";

@Controller("user")
export class UserController {
  constructor(
    // 写法1:
    // @Inject(Logger) private readonly logger: LoggerService
    // 写法2:
    private readonly logger: Logger
  ) {
    this.logger.log("init");
  }
}

缺点:需要打印日志的时候,要手动导入 logger

3.3 winston-daily-rotate-file 滚动日志

  1. 安装
npm i winston-daily-rotate-file
  1. 使用:在main.ts
  • import 'winston-daily-rotate-file'文件全部导入

  • new winston.transports.DailyRotateFile记录日志

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";
import "winston-daily-rotate-file";

async function bootstrap() {
  const instance = createLogger({
    transports: [
      new winston.transports.Console({
        level: "info",
        format: winston.format.combine(
          // winston.format.timestamp() 日志时间
          winston.format.timestamp(),
          utilities.format.nestLike()
        ),
      }),
      // 将日志记录到文件中
      new winston.transports.DailyRotateFile({
        level: "info",
        format: winston.format.combine(
          // winston.format.timestamp() 日志时间
          winston.format.timestamp(),
          winston.format.simple()
        ),
        dirname: "log",
        filename: "application-%DATE%.log",
        datePattern: "YYYY-MM-DD-HH",
        zippedArchive: true, // 文件压缩
        maxSize: "20m",
        maxFiles: "15d", // 文件保存时间:15天
      }),
    ],
  });

  const app = await NestFactory.create(AppModule, {
    logger: WinstonModule.createLogger({
      instance: instance,
    }),
  });
  app.setGlobalPrefix("/api/v1");
  const port = 3000;
  await app.listen(port);
}
bootstrap();
  • 根据上面 3.2 节,即可正常保存日志到文件中

3.4 全局异常过滤器配合 winston 记录日志

  • src/filtters/http-exception.filtter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  LoggerService,
} from "@nestjs/common";

@Catch(HttpException)
export class HttpExceptionFiltter implements ExceptionFilter {
  // 依赖注入
  constructor(private logger: LoggerService) {}

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    const status = exception.getStatus();

    // 记录错误日志
    this.logger.error(exception.message, exception.stack);

    response.status(status).json({
      code: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      msg: exception.message || HttpException.name,
    });
  }
}
  • main.ts

app.useGlobalFilters(new HttpExceptionFiltter(logger))传入 logger 记录日志

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { createLogger } from "winston";
import * as winston from "winston";
import { WinstonModule, utilities } from "nest-winston";
import "winston-daily-rotate-file";
import { HttpExceptionFiltter } from "./filtters/http-exception.filtter";

async function bootstrap() {
  const instance = createLogger({
    transports: [
      new winston.transports.Console({
        level: "info",
        format: winston.format.combine(
          // winston.format.timestamp() 日志时间
          winston.format.timestamp(),
          utilities.format.nestLike()
        ),
      }),
      new winston.transports.DailyRotateFile({
        level: "info",
        format: winston.format.combine(
          // winston.format.timestamp() 日志时间
          winston.format.timestamp(),
          winston.format.simple()
        ),
        dirname: "log",
        filename: "application-%DATE%.log",
        datePattern: "YYYY-MM-DD-HH",
        zippedArchive: true, // 文件压缩
        maxSize: "20m",
        maxFiles: "15d", // 文件保存时间:15天
      }),
    ],
  });

  const logger = WinstonModule.createLogger({
    instance: instance,
  });

  const app = await NestFactory.create(AppModule, {
    logger: logger,
  });
  app.setGlobalPrefix("/api/v1");

  // 传入logger记录日志
  app.useGlobalFilters(new HttpExceptionFiltter(logger));
  const port = 3000;
  await app.listen(port);
}
bootstrap();

3.5 日志模块代码重构

通过上面的知识,在main.ts中的代码非常的臃肿,需要将不同的模块进行抽离,下面将讲述如何抽离日志模块的代码。

  1. 通过 cli 创建日志模块
nest g mo logs

会在src下新建logs目录,并且新建logs.module.ts文件

import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { WinstonModule, WinstonModuleOptions, utilities } from "nest-winston";
import { Console } from "winston/lib/winston/transports";
import * as winston from "winston";
import * as DailyRotateFile from "winston-daily-rotate-file";

const consoleTransPorts = new Console({
  level: "info",
  format: winston.format.combine(
    // winston.format.timestamp() 日志时间
    winston.format.timestamp(),
    utilities.format.nestLike()
  ),
});

const dailyTransPorts = new DailyRotateFile({
  level: "info",
  format: winston.format.combine(
    // winston.format.timestamp() 日志时间
    winston.format.timestamp(),
    winston.format.simple()
  ),
  dirname: "log",
  filename: "application-%DATE%.log",
  datePattern: "YYYY-MM-DD-HH",
  zippedArchive: true, // 文件压缩
  maxSize: "20m",
  maxFiles: "15d", // 文件保存时间:15天
});

@Module({
  imports: [
    // 异步导入
    WinstonModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      // configService主要用来读取环境变量的
      useFactory: (configService: ConfigService) => {
        return {
          transports: [consoleTransPorts, dailyTransPorts],
        } as WinstonModuleOptions;
      },
    }),
  ],
})
export class LogsModule {}
  1. main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {});
  // 用winston的provider去替换nest的logger
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
  app.setGlobalPrefix("/api/v1");
  const port = 3000;
  await app.listen(port);
}
bootstrap();
  1. user.controller.ts文件中使用
import { Controller, Get, Inject, LoggerService } from "@nestjs/common";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";

@Controller("user")
export class UserController {
  constructor(
    // 注入logger
    @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService
  ) {
    this.logger.log("init");
  }

  @Get("user")
  findAll(): Object {
    this.logger.log("成功");
    return "ok";
  }
}

打印结果:

# Nest
[Nest] 38236  - 2024/07/16 03:46:03     LOG [InstanceLoader] UserModule dependencies initialized +4ms
# Winston
[NestWinston] 38236 2024/7/16 03:46:03     LOG  init

十六、测试

单元测试(unit test):测试的是局部函数的逻辑

集成测试(e2e test):测试的是整体的流程或者功能完整性

1. 单元测试:单元测试框架Jest

单元测试:后端主要是模拟单个接口方法是否正常

Jest 官网:https://jestjs.io/zh-Hans/

1.1 基本使用

注:该章用的是 nest 中简单对 jest 的使用,如果是其他框架,执行jest --watch

  1. 在终端执行npm run test:watch,会有以下内容
PASS  src/test.spec.ts
PASS  src/test/__test__/test.controller.spec.ts
PASS  src/test/__test__/test.service.spec.ts

Test Suites: 3 passed, 3 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        4.087 s
Ran all test suites related to changed files.

Watch Usage
 › Press a to run all tests. # 终端输入a,会进行所有单元测试
 › Press f to run only failed tests.
 › Press p to filter by a filename regex pattern. # 输入p,文件名正则表达式模式过滤
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.
  1. src下,新建test.spec.ts文件
describe("test jest hello world", () => {
  it("should be true", () => {
    expect(true).toBe(true);
  });
  it("should be true1", () => {
    expect(true).toBe(true);
  });
  it("should be true2", () => {
    expect(true).toBe(true);
  });

  describe("test jest hello world", () => {
    it("should be true", () => {
      expect(true).toBe(true);
    });
    it("should be true1", () => {
      expect(true).toBe(true);
    });
    it("should be true2", () => {
      expect(true).toBe(true);
    });
  });
});
  1. 终端输入p
Pattern Mode Usage
 › Press Esc to exit pattern mode.
 › Press Enter to filter by a filenames regex pattern.

 pattern › # 这里输入需要测试的文件路径,例如:src/test.spec.ts

会输入以下测试结果:

 PASS  src/test.spec.ts
  # 可以看到describe和it的层级关系
  test jest hello world
    √ should be true (6 ms)
    √ should be true1
    √ should be true2 (1 ms)
    test jest hello world
      √ should be true
      √ should be true1
      √ should be true2

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        1.93 s, estimated 4 s
Ran all test suites matching /src\\test.spec.ts/i.

Watch Usage: Press w to show more.

1.2 在 Nest 中使用

1.2.1 测试 controller

例如:Auth模块

src/auth/__test__/auth.controller.spec.ts文件中:

import { Test, TestingModule } from "@nestjs/testing";
import { AuthController } from "../auth.controller";
import { AuthService } from "../auth.service";
import { SigninUserDto } from "../dto/signin-user.dto";

describe("AuthController(登录认证模块)", () => {
  let controller: AuthController;
  /**
   * AuthController会依赖到AuthService,由于AuthService没有注入,会测试失败,需要模拟一个AuthService
   */
  let mockAuthService: Partial<AuthService>;

  beforeEach(async () => {
    // 模拟的AuthService,这样就与后续的依赖项无关联了:例如:用到了UserService等无关联的依赖
    mockAuthService = {
      // 因为该方法返回的是一个Promise<string>
      signin: (username: string, password: string) => {
        return Promise.resolve("token");
      },
    };

    // 假设了一个测试模块,和auth.module.ts中Module的作用是一样的
    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthController],
      providers: [
        {
          provide: AuthService,
          useValue: mockAuthService,
        },
      ],
    }).compile();

    // 通过module.get可以拿到AuthController实例
    controller = module.get<AuthController>(AuthController);
  });

  it("鉴权-初始化-实例化", () => {
    // 使用expect来断言controller是否被创建成功
    expect(controller).toBeDefined();
  });

  it("鉴权-控制器-signin注册", async () => {
    const res = controller.signin({
      username: "xiaotianwifi",
      password: "123456",
    } as SigninUserDto);
    expect(await res).not.toBeNull();
    expect((await res).access_token).toBe("token");
    /**
         * signin接口返回的是:
         *  return {
                access_token: token,
            }
         */
    // expect((await res).access_token).toBe('token1') // 测试失败
  });
});

测试结果为:

PASS  src/auth/__test__/auth.controller.spec.ts
  AuthController(登录认证模块)
    √ 鉴权-初始化-实例化 (15 ms)
    √ 鉴权-控制器-signin注册 (2 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.177 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.
1.2.2 测试 service

src/auth/__test__/auth.service.spec.ts文件中:

import { Test, TestingModule } from "@nestjs/testing";
import { AuthService } from "../auth.service";
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
import { UserService } from "../../user/user.service";
import { User } from "../../user/user.entity";
import { UnauthorizedException } from "@nestjs/common";

describe("AuthService测试", () => {
  let service: AuthService;
  let userService: Partial<UserService>; // UserService并不是所有方法都要实现,所以用Partial可选参数
  let jwt: Partial<JwtService>;
  // 用来模拟登录用户
  const mockUser = {
    username: "xiaotianwifi",
    password: "123456",
  };

  beforeEach(async () => {
    userService = {
      getUser: (username: string) => {
        return mockUser as User;
      },
    };

    jwt = {
      signAsync: (
        payload: string | object | Buffer,
        options?: JwtSignOptions
      ): Promise<string> => {
        return Promise.resolve("token");
      },
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [AuthService],
      providers: [
        AuthService,
        {
          provide: UserService,
          useValue: userService,
        },
        {
          provide: JwtService,
          useValue: jwt,
        },
      ],
    }).compile();

    service = module.get<AuthService>(AuthService);
  });

  it("鉴权-初始化-实例化-service", () => {
    expect(service).toBeDefined();
  });

  it("用户登录成功", async () => {
    const user = await service.signin(mockUser.username, mockUser.password);
    expect(user).toBe("token");
  });

  it("用户用户名密码错误", async () => {
    await expect(service.signin(mockUser.username, "123")).rejects.toThrow(
      new UnauthorizedException()
    );
  });
});

测试结果为:

PASS  src/auth/__test__/auth.service.spec.ts
  AuthService测试
    √ 鉴权-初始化-实例化-service (16 ms)
    √ 用户登录成功 (3 ms)
    √ 用户用户名密码错误 (9 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.801 s, estimated 3 s
Ran all test suites matching /src\\auth\\__test__\\auth.service.spec.ts/i.

Watch Usage: Press w to show more.

1.3 Jest 相关的 api

1.3.1 匹配器 expect
  1. 匹配器:就是做一次断言

  2. 匹配器的使用:

// expect是测试的值,toBe是期望的值
expect(1 + 1).toBe(2);
1.3.2 安装和移除
  1. 什么是安装和移除:js 测试生命周期的方法

  2. 有哪些:

beforeEach(() => {
  initializeCityDatabase();
});

afterEach(() => {
  clearCityDatabase();
});

beforeAll(() => {
  return initializeCityDatabase();
});

afterAll(() => {
  return clearCityDatabase();
});
  • beforeEach:每个测试用例之前

  • afterEach: 每个测试用例之后

  • beforeAll一次性设置,所有的测试用例之前

  • afterAll一次性设置,所有的测试用例之后

2. 集成测试

集成测试:模拟用户客户端发送请求,到服务端响应过程

流程:

  1. 创建 App 实例

  2. 数据库初始化

  3. 监听端口

  4. 接收请求并响应

  5. 测试完成清理测试数据(例如:生成的数据库数据)

2.1 基本使用

main.ts定义的全局配置,如管道、过滤器之类的,在测试用例里面不方便二次使用,所有可以将全局配置单独拆分到setup.ts

  1. src/setup.ts
import { INestApplication, ValidationPipe } from "@nestjs/common";
export function setup(app: INestApplication) {
  app.setGlobalPrefix("/api/v1");
  // 配置全局拦截器
  app.useGlobalPipes(
    new ValidationPipe({
      // 去除在类上不存在的字段
      whitelist: true,
    })
  );
}
  1. main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { setup } from "./setup";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  setup(app);
  await app.listen(3000);
}
bootstrap();
  1. /test/app.e2e-spec.ts
  • 使用:
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "./../src/app.module";
import { setup } from "./../src/setup";

describe("AppController (e2e)", () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    // app全局配置
    setup(app);
    await app.init();
  });

  it("/ (GET)", () => {
    return request(app.getHttpServer())
      .get("/api/v1/user")
      .expect(200)
      .expect("Hello World!");
  });
});
  • 测试结果:执行npm run test:e2e
PASS  test/app.e2e-spec.ts
  AppController (e2e)
    √ / (GET) (212 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.148 s
Ran all test suites.

2.2 自定义 AppFactory 及脚本完成数据库初始化与清理

  1. test目录下新建app.factory.ts文件
import { Test, TestingModule } from "@nestjs/testing";
import { AppModule } from "../src/app.module";
import { INestApplication } from "@nestjs/common";
import { setup } from "../src/setup";
import { DataSource } from "typeorm";
import datasource from "../src/ormconfig";

/**
 * 写法1:const app = new AppFactory().init() init -> return app实例
 * 写法2:OPP面向对象写法 get instance() -> app, private app, AppFactory constructor的部分进行初始化
 * 写法2使用:const appFactory = AppFactory.init()(因为AppFactory.init返回了一个AppFactory实例)  const app = appFactory.instance 获取app实例
 */
export class AppFactory {
  connection: DataSource;
  constructor(private app: INestApplication) {}

  get instance() {
    return this.app;
  }

  // 初始化App实例
  static async init() {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    const app = moduleFixture.createNestApplication();
    setup(app);
    const port = 3000;
    await app.listen(port);
    await app.init();
    return new AppFactory(app);
  }

  // 初始化db数据库
  async initDB() {
    // 如果数据库没有初始化就去初始化
    if (!datasource.isInitialized) {
      await datasource.initialize();
    }
    this.connection = datasource; // 该datasource是从ormconfig.ts文件中引入的,可以参考数据库typeorm部分
  }

  // 清楚数据库数据 -> 避免测试数据污染
  async cleanup() {
    // 获取到所有的数据库名称
    const entities = this.connection.entityMetadatas;
    for (const entity of entities) {
      // 获取到所有的存储库
      const repository = this.connection.getRepository(entity.name);
      // 删除所有表中数据
      await repository.query(`DELETE FROM ${entity.tableName}`);
    }
  }

  // 断开与数据库的链接 -> 避免后续数据库连接过多而无法连接
  async destory() {
    await this.connection.destroy();
  }
}
  1. test目录下新建setup-jest.ts文件
import { INestApplication } from "@nestjs/common";
import { AppFactory } from "./app.factory";

let appFactory: AppFactory;
let app: INestApplication;

// 将beforeEach和afterEach挂载到全局去使用
global.beforeEach(async () => {
  appFactory = await AppFactory.init();
  await appFactory.initDB();
  app = appFactory.instance;
});

global.afterEach(async () => {
  await appFactory.cleanup();
  // 解决测试后端口占用的问题
  await app.close();
});
  1. jest-e2e.json中:

确保 2 中的文件会在全局自动加载

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  // 添加如下代码
  "setupFilesAfterEnv": ["<rootDir>/setup-jest.ts"]
}
  1. 注意:如果有多个测试用例,会导致某些测试用例完成后清空数据库,可以在package.json中配置最大线程--maxWorkers=1
"test:e2e": "jest --config ./test/jest-e2e.json --maxWorkers=1"
  1. app.e2e-spec.ts中使用

注意:端对端(e2e)的测试的it必须要写return

import * as request from "supertest";

describe("AppController (e2e)", () => {
  it("/ (GET)", () => {
    return request(global.app.getHttpServer())
      .get("/api/v1/user")
      .expect(200)
      .expect("Hello World!");
  });
});

2.3 集成 pactum 测试库发起请求

  1. 安装
npm i pactum -D
  1. 使用
import * as pacutm from "pactum";

describe("AppController (e2e)", () => {
  beforeEach(() => {
    pacutm.request.setBaseUrl("http://127.0.0.1:3000");
  });

  it("/ (GET)", () => {
    return pacutm
      .spec()
      .get("/api/v1/user")
      .expectStatus(200)
      .expectBodyContains("Hello World!");
  });
});

十七、项目部署

1. pm2 的好处

  1. pm2 进程守护可以在程序崩溃后自动重启
  2. pm2 自带日志记录功能,可以很方便的记录错误日志和自定义日志
  3. pm2 可以启动多个 node 进程,充分利用服务器资源

2. pm2 的基本使用

2.1 安装 pm2

npm i pm2 -g

2.2 查看 pm2 版本

pm2 --version

2.3 启动 pm2

pm2 start index.js

3. pm2 常用指令

3.1 启动应用程序

pm2 start index.js

3.2 列出启动的所有应用程序

pm2 list

3.3 重启应用程序

pm2 restart 应用id/name

3.4 杀死并重启所有进程

pm2 restart all

3.5 查看应用程序详细信息

pm2 info 应用id/name

pm2 show

3.6 显示指定应用程序的日志

pm2 log 应用id/name

3.7 监控应用程序

pm2 monit 应用id/name

3.8 停止应用程序

pm2 stop 应用id/name

3.9 停止所有应用程序

pm2 stop all

3.10 关闭并删除指定应用程序

pm2 delete 应用id/name

3.11 关闭并删除所有应用程序

pm2 delete all

3.12 杀死 pm2 管理的所有进程

pm2 kill

4. pm2 常用配置

  1. 新建pm2.confif.json文件
{
  "name": "应用程序名称",
  "script": "入口文件名称",
  "watch": true, // 文件被修改是否自动重启
  "ignore_watch": [
    // 忽略监听哪些文件的改变
    "node_modules",
    "logs"
  ],
  "error_file": "logs/错误日志文件名称.log",
  "out_file": "logs/自定义日志文件名称.log",
  "log_date_format": "yyyy-MM-dd HH:mm:ss" // 给日志添加时间
}
  1. 运行
pm2 start pm2.config.json

5. 负载均衡

node 是单线程的,服务器是多核的,要充分利用服务器资源,可以使用 pm2 启动多个 node 进程,只需要在配置文件中增加 instances 配置,可以启动多个 node 进程(想启动几个就启动几个,但是不能超过服务器 cpu 的核数)

{
  "name": "应用程序名称",
  "script": "入口文件名称",
  "watch": true, // 文件被修改是否自动重启
  "ignore_watch": [
    // 忽略监听哪些文件的改变
    "node_modules",
    "logs"
  ],
  "error_file": "logs/错误日志文件名称.log",
  "out_file": "logs/自定义日志文件名称.log",
  "log_date_format": "yyyy-MM-dd HH:mm:ss", // 给日志添加时间
  "instances": 4 // 开启多个node进程,不能超过cpu核数
}

十八、OpenAPI 生成接口文档

1. 安装

npm install --save @nestjs/swagger

2. 使用

安装过程完成后,打开 main.ts 文件并使用 SwaggerModule 类初始化 Swagger:

import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle("接口文档标题")
    .setDescription("接口文档描述")
    .setVersion("1.0")
    .addTag("cats")
    .build();
  const document = SwaggerModule.createDocument(app, config);
  // docs文档url:通过http://localhost:3000/docs
  SwaggerModule.setup("docs", app, document);

  await app.listen(3000);
}
bootstrap();

docs 文档 url:通过http://localhost:3000/docs访问 swagger 文档

文档 json 数据:通过http://localhost:3000/docs-json访问

十九、补充

1. session

session 是服务器为每个用户的浏览器创建的一个会话对象,这个 session 会记录到浏览器的 cookie 来区分用户。

在 nest 中默认框架是 express,可以安装 express 的 session 使用

1.1 安装

npm i express-session -S
npm i @types/express-session -D

1.2 使用

  1. main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import * as session from "express-session";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("/api/v1");
  app.use(
    session({
      secret: "xiaotian", // 生成服务端session,可以理解为加盐
      rolling: true, // 在每次请求时强行设置cookie,这将充值cookie的过期时间(默认为false)
      name: "xiaotian_sid", // 生成客户端cookie的名字,默认为connect.sid
      cookie: {
        maxAge: 9999999, // 过期时间
      }, // 设置返回到前端的key属性,默认值为{path: '/, httpOnly: true, secure: false, maxAge: null}
    })
  );
  await app.listen(3000);
}
bootstrap();

2. 生成图片验证码

2.1 安装

npm install svg-captcha -S

2.2 使用

注意:前端请求时需要携带 cookie

import {
  Controller,
  Get,
  Post,
  Body,
  Param,
  Request,
  Query,
  Headers,
  HttpCode,
  Res,
  Req,
} from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import * as svgCaptcha from "svg-captcha";
@Controller("user")
export class UserController {
  constructor(private readonly userService: UserService) {}
  @Get("code")
  createCaptcha(@Req() req, @Res() res) {
    // 也可通过@Session
    const captcha = svgCaptcha.create({
      size: 4, //生成几个验证码
      fontSize: 50, //文字大小
      width: 100, //宽度
      height: 34, //高度
      background: "#cc9966", //背景颜色
    });
    req.session.code = captcha.text; //存储验证码记录到session
    // 返回图片格式
    res.type("image/svg+xml");
    res.send(captcha.data);
  }

  @Post("create")
  createUser(@Req() req, @Body() body) {
    console.log(req.session.code, body);
    if (
      req.session.code.toLocaleLowerCase() === body?.code?.toLocaleLowerCase()
    ) {
      return {
        message: "验证码正确",
      };
    } else {
      return {
        message: "验证码错误",
      };
    }
  }
}

3. 文件上传

3.1 安装multer

@nestjs/platform-express:Nest自带了

npm i multer -s
npm i @types/multer -D

3.2 生成目录

nest g res upload

3.3 使用

  1. upload.module.ts
import { Module } from '@nestjs/common';
import { UploadService } from './upload.service';
import { UploadController } from './upload.controller';
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname, join } from 'path';

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        destination: join(__dirname, '../images'), // 存放目录
        filename: (req, file, callback) => {
          // extname可以截取文件后缀
          const fileName = `${new Date().getTime() + extname(file.originalname)}`
          return callback(null, fileName) // callback(error: Error, filename: string): void
        }
      }) // 文件上传存放位置
    })
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}

运行项目会在根目录生成的dist目录下会有images目录

  1. upload.controller.ts
  • 会使用到@UseInterceptors这个中间件,FileInterceptorFilesInterceptor是nest内置的专门用来处理文件的。

FileInterceptor:处理单文件上传

FilesInterceptor:处理多文件上传

  • 获取文件

@UploadedFile来获取文件对象

import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { UploadService } from './upload.service';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';

@Controller('upload')
export class UploadController {
  @Post()
  // file_1 是文件上传的参数名
  @UseInterceptors(FileInterceptor('file_1')) // 专门用来处理文件的中间件
  upload(@UploadedFile() file: File) {
    return file
  }
}

  1. 响应结果
{
    "fieldname": "file_1",
    "originalname": "1.webp.jpg",
    "encoding": "7bit",
    "mimetype": "image/jpeg",
    "destination": "C:\\Users\\Administrator\\Desktop\\nest-notes\\use_demo\\dist\\images",
    "filename": "1721495863226.jpg",
    "path": "C:\\Users\\Administrator\\Desktop\\nest-notes\\use_demo\\dist\\images\\1721495863226.jpg",
    "size": 81355
}

4. 配置静态资源

使用useStaticAssets, 在main.ts中配置静态资源访问

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

async function bootstrap() {
  // NestExpressApplication 提供类型推断
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // 用来配置静态资源目录
  app.useStaticAssets(join(__dirname, 'images'), {
    prefix: '/wifi' // 加访问路径前缀
  })
  await app.listen(3000);
}
bootstrap();

5. 文件下载

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express'
import { join } from 'path';

@Controller('download')
export class DownloadController {
  @Get()
  download(@Res() res: Response) {
    const url = join(__dirname, '../images/1721495863226.jpg')
    res.download(url)
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值