Skip to content

深入API-2

与其它的技术进行集成 , 比如数据库、配置、验证、高速缓存、序列化、Job、log、cookie、事件、文件上传.....等话题的深入探讨 github branch 在 other_tech 分支

数据库集成( Mysql + MongoDB )

由于前面的 一期 已经简约的说明了Mysql的使用 这里就不再另写了,我们只补充没有说明的地方

Mysql

mysql 相关的内容 我再以前一期的文章有详细的说明,请移步到这里 一期文章

MongoDB

从零开始 集成

一些基础的mongodb 技术,我之前的文章也有详细的提到,请参见 Nodejs + MongoDB

  1. 使用 docker 部署 mongodb Mongo
shell
$ docker pull mongo
$ docker run -itd --name mongo -p 27017:27017 mongo --auth
$ docker exec -it mongo
# 进入这个 容器 里面
$  mongosh 
# 注意是mongosh 不再是mongo  https://www.mongodb.com/docs/manual/release-notes/6.0-compatibility/#legacy-mongo-shell-removed

# 创建一个名为 admin,密码为 123456 的用户。
> db.createUser({ user:'admin',pwd:'123456',roles:[ { role:'userAdminAnyDatabase', db: 'admin'},"readWriteAnyDatabase"]});
# 尝试使用上面创建的用户信息进行连接。
> db.auth('admin', '123456')
  1. 开始集成

先来安装必要的依赖

shell
yarn add  @nestjs/mongoose mongoose

建立连接

ts
// 我自己新建了一个 core module 核心的功能逻辑都可以丢这里来
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
  imports: [
    MongooseModule.forRoot('mongodb://admin:123456@127.0.0.1:27017/testDB', {
      useUnifiedTopology: true,
      useNewUrlParser: true,
      authSource: 'admin',
    }),
  ],
  controllers: [], 
  providers: [],
})
export class CoreModule {}

创建模型

ts
// 建议文件夹放到 module 相关性的文件夹下
import { Prop, Schema, SchemaFactory, raw } from '@nestjs/mongoose';
import { Document } from 'mongoose';

// 声明 document 类型
export type CatDocument = Cat & Document;

// 定义 这个schema class
// @Schema()  当然你可以使用 DefinitionsFactory 来生产一个更原始的 schema

// @Schema 会把 cat 映射到 同名的 cat 复数 Collection 中去
// 注意这个 @Schema  可以接受更多的参数 (https://mongoosejs.com/docs/guide.html#options)
@Schema({
  autoIndex: true,
})
export class Cat extends Document {
  // @Props 非常强大 不仅可以 定义类型 也可以定义验证规则,详细请移步  https://mongoosejs.com/docs/schematypes.html
  @Prop()
  name: string;

  @Prop()
  age: number;

  @Prop()
  breed: string;

  @Prop([String])
  tags: string[];

  @Prop({ required: true })
  sex: string;

  @Prop(
    raw({
      firstName: { type: String },
      lastName: { type: String },
    }),
  )
  details: Record<string, any>;
}

export const CatSchema = SchemaFactory.createForClass(Cat);

// 如果不习惯使用装饰器 可以 直接 monogose
// import * as mongoose from 'mongoose';
// export const CatSchema = new mongoose.Schema({
//   name: String,
//   age: Number,
//   breed: String,
// });

特别提醒 你find 或者 exec 下来都是 一个model ,类型来自mongoose, 用它可以完成各种关联或者聚合查询, 这里不多说了 详见我的另一文章 (https://juejin.cn/post/7104641797514592293)

使用

ts
// module
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { CatController } from './cat.controller';
import { CatService } from './cat.service';
import { Cat, CatSchema } from './schemas/cat.schema';

@Module({
  imports: [
    MongooseModule.forFeature(
      [
        {
          name: Cat.name,
          schema: CatSchema,
        },
      ],
    ),
  ],
  controllers: [CatController],
  providers: [CatService],
})
export class CatModule {}


// service
import { Injectable } from '@nestjs/common';
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
import { Connection, Model } from 'mongoose';
import { Cat, CatDocument } from './schemas/cat.schema';

@Injectable()
export class CatService {
  // 注入model / 连接池(后者的功能要更强大 (https://mongoosejs.com/docs/api/connection.html#Connection())
  constructor(
    @InjectModel('Cat') private catModel: Model<CatDocument>,
    @InjectConnection() private connection: Connection,
  ) {}

  getHello(): string {
    return 'Hello World!';
  }

  async create(data: any): Promise<Cat> {
    const createCat = new this.catModel(data);
    return createCat.save();
  }
}

@Controller('cat')
export class CatController {
  constructor(private readonly catService: CatService) {}

  @Post('create')
  create(@Body() body: any) {
    return this.catService.create(body);
  }
}

其他的高级操作

ts
// 如果需要 多数据库连接请使用
// core
  imports: [
    MongooseModule.forRoot('mongodb://admin:123456@127.0.0.1:27017/testDB', {
      useUnifiedTopology: true,
      useNewUrlParser: true,
      authSource: 'admin',
      connectionName: 'testDB',
    }),
    MongooseModule.forRoot('mongodb://admin:123456@127.0.0.1:27017/testDB2', {
      useUnifiedTopology: true,
      useNewUrlParser: true,
      authSource: 'admin',
      connectionName: 'testDB2',
    }),
  ],

// module
imports: [
    MongooseModule.forFeature(
      [
        {
          name: Cat.name,
          schema: CatSchema,
        },
      ],
      'testDB',
    ),
    MongooseModule.forFeature(
      [
        {
          name: Cat.name,
          schema: CatSchema,
        },
      ],
      'testDB2',
    ),
  ],

  // service
  @Injectable()
export class CatService {
  constructor(
    @InjectModel('Cat', 'testDB2') private catModel: Model<CatDocument>,
  ) {}

  getHello(): string {
    return 'Hello World!';
  }

  async create(data: any): Promise<Cat> {
    const createCat = new this.catModel(data);
    return createCat.save();
  }
}

mongoDB 中间件 和 plugin 的使用

ts
// 间件是针对Schema层级的 (https://mongoosejs.com/docs/middleware.html)

 MongooseModule.forFeatureAsync([
      {
        name: 'Cat',
        useFactory: () => {
          const schema = CatsSchema;
          // 了解 mongoose 的朋友都知道 这个基本上就上mongoose 的基操
          schema.pre('save', () => console.log('Hello from pre save'));
          return schema;
        },
      },
    ]),

// 类似的 plugin 也是如此 https://mongoosejs.com/docs/plugins.html

// core 
// 要向所有 schema 中立即注册一个插件,调用Connection对象中的.plugin()方法。你可以在所有模型创建前访问连接。使用connectionFactory来实现:
MongooseModule.forRoot('mongodb://localhost/test', {
      connectionFactory: (connection) => {
        connection.plugin(require('mongoose-autopopulate'));
        return connection;
      },
    }),

// module
// 仅对 某个scheme
   MongooseModule.forFeatureAsync([
      {
        name: 'Cat',
        useFactory: () => {
          const schema = CatsSchema;
          schema.plugin(require('mongoose-autopopulate'));
          return schema;
        },
      },
    ]),
  ],

对于测试(单元测试什么的 一般而言我们通常希望避免任何数据库连接

解决方案是创建模拟model

ts
@Module({
  providers: [
    CatsService,
    {
      provide: getModelToken('Cat'), // getModelToken @nestjs/mongoose 它返回一个 硬编码 实例对象
      useValue: catModel,
    },
  ],
})
export class CatsModule {}

对于数据的关联操作,请直接参考 这里

配置

一期有详细的描述 不做过多赘述

验证

我们讲一下细节问题, 我们的 validation 和 pip紧密相关,它们结合起来使用 不仅仅可以做数据的验证还可以做数据的转换

Nest 会内置 9 个开箱即用 的pip,它们分两类

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

Parse* 类型的Pip

这里的都是 Parse 开头的 pip 主要用来验证 值是否合法, 使用非常的简单

ts
import {
  Controller,
  Get,
  HttpStatus,
  Param,
  ParseIntPipe,
  Query,
} from '@nestjs/common';
@Controller('pip')
export class PipController {
  @Get('t1/:id')
  test1(@Param('id', ParseIntPipe) id: number) {
    // 参数级别绑定
    return 't1';
  }

  @Get('t2/:id')
  test2(
    @Param(
      'id',
      new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
    )
    id: number,
  ) {
    // 使用实例 而不是class class会在ioc 容器内部处理掉
    return 't2';
  }

  @Get('t3')
  test3(@Query('id', ParseIntPipe) id: number) {
    return 'te';
  }
}

Validation类型的Pip

实际上上述的 9个 只有两个是特例 ValidationPipe + DefaultValuePipe

要使用 nest内置的 ValidationPipe 需要install 两个依赖, 它的功能非常的强大 也更加的灵活

shell
yarn add class-validator class-transformer

最常见的使用方式 全局自动加上 ValidationPipe,然后结合 class-validator 提供的装饰器,我们就可以非常快捷的写出 强大且合适的 pip

new ValidationPipe 有许多可选参数,下面是一览表

nametypesdes
enableDebugMessagesboolean如果设置为 true ,验证器会在出问题的时候打印额外的警告信息
skipUndefinedPropertiesboolean如果设置为 true ,验证器将跳过对所有验证对象中值为 null 的属性的验证
skipNullPropertiesboolean如果设置为 true ,验证器将跳过对所有验证对象中值为 null 或 undefined 的属性的验证
skipMissingPropertiesboolean如果设置为 true ,验证器将跳过对所有验证对象中缺失的属性的验证
whitelistboolean如果设置为 true ,验证器将去掉没有使用任何验证装饰器的属性的验证(返回的)对象
forbidNonWhitelistedboolean如果设置为 true ,验证器不会去掉非白名单的属性,而是会抛出异常
forbidUnknownValuesboolean如果设置为 true ,尝试验证未知对象会立即失败
disableErrorMessageboolean如果设置为 true ,验证错误不会返回给客户端
errorHttpStatusCodenumber这个设置允许你确定在错误时使用哪个异常类型。默认抛出 BadRequestException
exceptionFactoryFunction接受一个验证错误数组并返回一个要抛出的异常对象
groupsstring[]验证对象时使用的分组
alwaysboolean设置装饰器选项 always 的默认值。默认值可以在装饰器的选项中被覆写
strictGroupsboolean忽略在任何分组内的装饰器,如果 groups 没有给出或者为空
dismissDefaultMessagesboolean如果设置为 true ,将不会使用默认消息验证,如果不设置,错误消息会始终是 undefined
validationError.targetboolean确定目标是否要在 ValidationError 中暴露出来
validationError.valueboolean确定验证值是否要在 ValidationError 中暴露出来
stopAtFirstErrorboolean如果设置为 true ,对于给定的属性的验证会在触发第一个错误之后停止。默认为 false
  1. 简单实用
ts
// 当然我们可以全局开 也可以 局限于 controller / 具体额路由上
app.useGlobalPipes(new ValidationPipe());

// 例子🌰 
@Post()
@UsePipes(new ValidationPipe())
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

// 新建DTO
import { IsEmail, IsNotEmpty } from 'class-validator';

export class TestDto {
  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;
}

// 注意不要到处 type TestDto
// ts类型会在运行时被擦除,记得用 import { CreateUserDto } 而不是 import type { CreateUserDto }

// 使用DTO
  @Post('t4')
  test4(@Body() testDto: TestDto) {
    return 't4';
  }
  1. 控制白名单
ts

// 如果你需要使用白名单 可以这样操作
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    // forbidNonWhitelisted:true 若你希望 遇到白名单的时候 返回错误 请开启它
  })
);

// 在DTO 中不加任何 装饰的 就是白名单的字段
export class TestDto {
  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;

  name: string
}
  1. 进行转换

注意 paser* 开头的会自动转化

ts
findOne(
  @Param('id', ParseIntPipe) id: number,
  @Query('sort', ParseBoolPipe) sort: boolean,
) {
  console.log(typeof id === 'number'); // true
  console.log(typeof sort === 'boolean'); // true
  return 'This action returns a user';
}

// 我们经常有这样的需求:create 的时候希望传递全部,modify的时候需要多传一个 id,有没有办法方便的实现呢?或者修改的时候某个字段又变成可选的了

// Nest 提供了 PartialType() 函数来让这个任务变得简单
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

// PartialType() 函数是从 @nestjs/mapped-types 下面的例子 所有的参数都是可选的
export class UpdateCatDto extends PartialType(CreateCatDto) {}

// 如果你对 ts 熟悉的话,PickT、Omit...... 我就不多介绍了请看下面的代码
export class CreateCatDto {
  name: string;
  breed: string;
}
export class AdditionalCatInfo {
  color: string;
}
export class UpdateCatDto extends IntersectionType(
  CreateCatDto,
  AdditionalCatInfo,
) {}

// 若要验证Array 下面这种事不行的 请使用
@Post()
createBulk(@Body() createUserDtos: CreateUserDto[]) {
  return 'This action adds new users';
}

@Post()
createBulk(
  @Body(new ParseArrayPipe({ items: CreateUserDto }))
  createUserDtos: CreateUserDto[],
) {
  return 'This action adds new users';
}

// 如果你需要手动处理 array 比如 GET /?ids=1,2,3
@Get()
findByIds(
  @Query('id', new ParseArrayPipe({ items: Number, separator: ',' }))
  ids: number[],
) {
  return 'This action returns users by ids';
}


// 更多的 请看官方文档 https://docs.nestjs.com/techniques/validation

自定义Pip

实际上的 也就是 一个 功能可自定义的 validation

ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
// import { ValidationPipe } from '@nestjs/common';

@Injectable()
export class MyValidationPipe implements PipeTransform {
  // 必须要实现的一个方法
  transform(value: any, metadata: ArgumentMetadata) {
    // 这里可以进行转化 / threw error

    // Value 就是输入的value
    // metadata 是所谓的 元数据 。比如string isRequired 这种

    // 具体的实现 可以参考 ValidationPipe 的源码实现
    return value;
  }
}

缓存问题

Nest 提供了一个 非常快捷 且不需要 其它基础设施(redis 之类的 缓存工具)

注意 历史版本问题 v5 的 cache-manager 使用 毫秒作为 ttl(Time-To-Live), v4 版本使用的 单位是 秒,为了与官方文档保存一致 我们使用v4版本,当然使用v5 也可以的 ,只不过需要自己去实现一些方法 工作效率也许 不如直接使用v4版本

shell
$ yarn add cache-manager
$ yarn add  @types/cache-manager -D
$ yarn add  cache-manager-redis-store

# redis 的docker 安装简单指南
$ docker pull redis:latest    
# $ docker run -itd --name redis-test -p 6379:6379 redis 这对后续的多容器交互 不太方便
$ docker run -itd --name redis-test -p redis-test:6379:6379 redis

下面是一个简单的

ts
// 注入module
@Module({
  imports: [CacheModuleNest.register({
      ttl: 5, //秒 默认过期时间
      max: 10, //缓存中最大和最小数量
  })],
  ++++
// 使用API 
import { CACHE_MANAGER, Controller, Get, Inject } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Controller('cache')
export class CacheController {
  constructor(@Inject(CACHE_MANAGER) private cacheManger: Cache) {}

  @Get('t1')
  async test1() {
    // 简单的使用就是这样用的,默认缓存过期是 5s
    const cacheValue = await this.cacheManger.get('key1');

    if (cacheValue) {
      console.log('cacheValue', cacheValue);

      return cacheValue;
    }

    const value = Math.floor(Math.random() * 10);
    await this.cacheManger.set('key1', value);
    // 可以设置过期时间 若=0 永不过期

    return value;
  }
}

我们可以使用 Cahce提供的 interceprot 实现全局的 缓存 注意全局的缓存 仅对 get方法有效,(注意 默认全局配置的话 使用 URL 并不全req RUL 而是它的路径 比如 '/cache/t1' 作为key)

ts
// 应用在某一个 controller 上 
@Controller('cache')
@UseInterceptors(CacheInterceptor)
export class CacheController {
}

// 直接应用在全局上 (AppModule)
@Module({
  imports: [CacheModule.register()],
  controllers: [AppController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

// 但是我 又需要全局 又需要自定义 呢?
// 对于微服务 或者grpc 同样适用 (但是注意 必须加 key CacheKey,指定一个用于依次存储并获取缓存数据的键,永远也不要去缓存那些用于实现业务逻辑也不是简单地查询数据的行为。
  @CacheKey('custom_key')
  @CacheTTL(20)
  findAll(): string[] {
    return [];
  }

关于自定义到 redis 中去 (当然不止redis ,其它的当然也可以 https://github.com/node-cache-manager/node-cache-manager#store-engines

ts
// 很简单....
import * as redisStore from 'cache-manager-redis-store';

@Module({
  imports: [
    CacheModuleNest.register({
      store: redisStore,
      host: 'localhost',
      port: 6379,
    }),
  ],
  controllers: [CacheController],
  providers: [],
})
export class CacheModule {}

当然Nest 允许 你自己去定义 key的追踪方式 ,只需要实现它,然后复写它就好了, (Java即视感)

ts
// 比如 我如果想要别的东西 去自定义 key 就可以这样写
@Injectable()
class HttpCacheInterceptor extends CacheInterceptor {
  trackBy(context: ExecutionContext): string | undefined {
    return 'key';
  }
}

序列化问题

这个很简单 仅仅是 “在返回数据的出去的时候 从dto/entity/schema 中排出某些字段/修改返回的规则,但是还可以保持类型验证不报错”

我提交了一个PR到CN文档,并且已经合并,官方V9和原中文文档 有出入

ts
// 1. 定义 Entity ------------
import { Exclude, Expose, Transform } from 'class-transformer';

export class RoleEntity {
  id: number;
  name: string;

  constructor(partial: Partial<RoleEntity>) {
    Object.assign(this, partial);
  }
}

// 比如这样的实体
export class UserEntity {
  id: number;
  firstName: string;
  lastName: string;
  _pid: number;

  // 排出某个
  @Exclude()
  password: string;

  // 修改别名  class 的get 和set 方法 非常基础的js知识 不赘述
  @Expose()
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  // 如果你需要 返回其中的 某个 而不是整个object 可以使用
  @Transform(({ value }) => value.name)
  role: RoleEntity;

  // 如果你 需要使用 深入到底层 请修改
  // 传递的选项作为底层 classToPlain() 函数的第二个参数传递。在本例中,我们自动排除了所有以_前缀开头的属性。
  // 详细见  serialization.controller @SerializeOptions 方法

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial);
  }
}


// 2. 使用规则 ------------

@Controller('serialization')
export class SerializationController {
  // 不建议 全局使用 因为有可能会存在问题 除非你仔细评估之后 确实可以全局
  @SerializeOptions({
    excludePrefixes: ['_'], // _ 开头的属性 全排出
  })
  @UseInterceptors(ClassSerializerInterceptor)
  @Get('t1')
  getT1() {
    // 只要这个返回的data 类能够在 ClassSerializerInterceptor 中 反射出class 就可以正确解析和处理逻辑
    return {
      users: [
        new UserEntity({
          firstName: 'Joney',
          lastName: 'SLI',
          password: 'password',
          _pid: 0,
          role: new RoleEntity({
            id: 1,
            name: 'admin',
          }),
        }),
        new UserEntity({
          firstName: 'Joney',
          lastName: 'SLI',
          password: 'password',
          _pid: 0,
          role: new RoleEntity({
            id: 1,
            name: 'admin',
          }),
        }),
      ],
    };
  }
}

但... 实际的运用中却并不是这样,nest提供的 interceptor 不够灵活 比如用在我们前面的mongo上 就g, 或者我们有更复杂的 typeOrm entity 比如各种关联 也会g,故我们需要改造

原理:请 参照 ClassSerializerInterceptor 自定义,( Interceptor 的基础和高阶用法,我都详细的介绍过,所以讲道理 你应该会哈)

ts
// 在cat.schema.ts 定义中 添加 entity
export class CatEntity {
  _id: ObjectId;
  name: string;
  age: number;
  tags: string[];
  sex: string;
  details: Record<string, any>;
  @Exclude()
  breed: string;

  @Expose()
  get id(): string {
    return this._id.toString();
  }

  constructor(partial: Partial<CatEntity>) {
    Object.assign(this, partial);
  }
}

// 定义一个装饰器 把 CatEntity 挂上去(为什么要这样做,因为 mongoose 返回的是model ,
// 原ClassSerializerInterceptor 无法放射出正确的 class 和装饰器 附加的内容

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

export const MONGO_ENTITY_CLASS = 'MongoEntityClass';
export const MongoEntityClass = (entity: any) =>
  SetMetadata(MONGO_ENTITY_CLASS, entity);


// extends 并重写其中的部分方法 ClassSerializerInterceptor
import {
  CallHandler,
  ClassSerializerInterceptor,
  ExecutionContext,
  Injectable,
  PlainLiteralObject,
} from '@nestjs/common';
import { isArray } from 'class-validator';
import {} from 'class-transformer';
import { TransformerPackage } from '@nestjs/common/interfaces/external/transformer-package.interface';
import { loadPackage } from '@nestjs/common/utils/load-package.util';
import { Observable, map } from 'rxjs';
import { MONGO_ENTITY_CLASS } from '../decorator/MongoEntity.decorator';

/**
mongodb query 出来的是一个model 而不是 一个可以被 Serializer 解析的 class
故我们添加转化配置 
 (1. 添加装饰器指定 entity,
 (2. 添加 新的 Interceptor 继承 ClassSerializerInterceptor 并且重写里面的方法
 */

@Injectable()
export class ClassSerializerMongoModelInterceptor extends ClassSerializerInterceptor {
  transform(MongoEntity, data) {
    return Array.isArray(data)
      ? data.map((obj) => new MongoEntity(obj.toObject()))
      : new MongoEntity(data.toObject());
  }

  // 重写这个方法
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const MongoEntity = this.reflector?.getAllAndOverride(MONGO_ENTITY_CLASS, [
      context.getHandler(),
      context.getClass(),
    ]);

    const contextOptions = this.getContextOptions(context);
    const options = {
      ...this.defaultOptions,
      ...contextOptions,
    };

    return next.handle().pipe(
      map((data) => {
        return this.transform(MongoEntity, data);
      }),
      map((res: PlainLiteralObject | Array<PlainLiteralObject>) => {
        return this.serialize(res, options);
      }),
    );
  }
}

// 使用
@Controller('cat')
@SerializeOptions({
  excludePrefixes: ['__v', '_id'],
})
@UseInterceptors(ClassSerializerMongoModelInterceptor)
export class CatController {
  constructor(private readonly catService: CatService) {}

  @Post('create')
  create(@Body() body: any) {
    return this.catService.create(body);
  }

  @MongoEntityClass(CatEntity)
  // @UseInterceptors(ClassSerializerMongoModelInterceptor)
  // 或者丢到全局去
  @Get('cat')
  async getCat(): Promise<Array<Cat>> {
    const value = await this.catService.getCat();
    log(value);
    return value;
  }
}

当然如果你觉得 加一个装饰器过于麻烦我们还可以 在toObject 的时候 复写 schema 然后自定义装饰器的 把它转出来

ts
@Schema({
  autoIndex: true,
  toObject: {
    transform: (doc, ret, options) => {
      const value = new CatEntity(doc.toJSON());
      Object.setPrototypeOf(ret, Object.getPrototypeOf(value));
      return ret;
    },
  },
})
export class Cat extends Document {}

// 这样你就不需要再 用 @MongoEntityClass 了 ,具体用那个方式 取决你自己的爱好

@Injectable()
export class ClassSerializerMongoModel2Interceptor extends ClassSerializerInterceptor {
  // 扩展一个方法
  transform(data) {
    return Array.isArray(data)
      ? data.map((obj) => obj.toObject())
      : data.toObject();
  }

  // 重写这个方法
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const contextOptions = this.getContextOptions(context);
    const options = {
      ...this.defaultOptions,
      ...contextOptions,
    };
    return next.handle().pipe(
      map((data) => {
        return classToPlain(this.transform(data));
      }),
      map((res: PlainLiteralObject | Array<PlainLiteralObject>) => {
        return this.serialize(res, options);
      }),
    );
  }
}

// 这样用的时候 就 不需额外加一个 decorator 了
@Controller('cat')
@SerializeOptions({
  excludePrefixes: ['__v', '_id'],
})
@UseInterceptors(ClassSerializerMongoModel2Interceptor)
export class CatController {
  constructor(private readonly catService: CatService) {}

  @Post('create')
  create(@Body() body: any) {
    return this.catService.create(body);
  }

  // @MongoEntityClass(CatEntity)
  // @UseInterceptors(ClassSerializerMongoModelInterceptor)
  // 或者丢到全局去
  @Get('cat')
  async getCat(): Promise<Array<Cat>> {
    const value = await this.catService.getCat();
    log(value);
    return value;
  }
}

小结一下,实际的工作经验中,我们往往不会返回 entity 也不会在上面加很多东西,装饰器Serializer 什么的,我们更喜欢自定义一个 新的class 去做 Serializer 因为这更加的自由

队列问题

队列是一种有用的设计模式,可以帮助你处理一般应用规模和性能的挑战 比如下面的一些场景 你就用得上了 官方参考文档

  1. 平滑输出峰值
  2. 堵塞行的任务打碎
  3. 不同服务见的 通信(比如服务发现...

安装必要的依赖

shell
yarn add @nestjs/bull bull 
yarn add @types/bull -D
ts
// 我们需要先注册 模块和队列
@Module({
  imports: [
    // 注册队列 名为audio 它有很多属性,请参考源代码中的 注释
    BullModule.forRoot({
      redis: {
        host: 'localhost',
        port: 6379,
      },
    }),
    BullModule.registerQueue({
      name: 'audio',
    }),
  ],
  controllers: [QueueController],
  providers: [QueueModuleService, AudioConsumer, AudioService],
})
export class QueueModule {}

// 然后去模块中声明我们的队列名
// 要用好队列我们需要理解一些基础概念
// 1. 队列 可以理解为一个组/group
// 2. 生产者 任务生产者添加任务到队列中
// 3. 消费者 消费者消费生产者发布的任务
// 4. 监听者 监听某个队列中的 某个任务的状态/注意多线程的问题

// 定义 生产者 订阅者 + 监听者(一般监听者 和生产者在一起

// 生产者 push 一个job 到队列中去
@Controller('queue')
export class QueueController {
  constructor(
    @InjectQueue('audio') private audioQueue: Queue,
    private readonly audioConsumer: AudioConsumer,
  ) {}

  // 任务表现为序列化的JavaScript对象(因为它们被存储在 Redis 数据库中)
  async startAudioQueue() {
    // 注意任务有许多可选项配置 详见下文文档
    const job = await this.audioQueue.add('transcode', {
      foo: 'bar',
    });

    return job;
  }

  // 队列管理
  async pauseQueue() {
    await this.audioQueue.pause();
  }

  async resumeQueue() {
    await this.audioQueue.resume();
  }

    @Get('start')
  async start() {
     await this.startAudioQueue();
     return 'ok'
  }
++++
}

// AudioConsumer 消费者和监听者
import {
  Processor,
  Process,
  OnQueueActive,
  OnGlobalQueueCompleted,
  OnQueueCompleted,
} from '@nestjs/bull';
import { Job } from 'bull';

@Processor('audio')
export class AudioConsumer {
  // 消费者
  // 在工作空闲或者队列中有消息要处理的时候被自动调用
  @Process('transcode')
  async transcode(job: Job<unknown>) {
    // 注意这个参数 仅作为参数,处理完之后才可以访问 也就是 doSomething之后
    let progress = 0;
    for (let i = 0; i < 100; i++) {
      progress += 10;
      job.progress(progress);
      // progress 用来更新 job进程
    }
    return progress;
    // 它返回一个 数据JS object
  }

  // 监听者1 Active时监听 注意有许多种类型的状态监听 而且还会区分 全局(分布式/ 本地的 两类
  @OnQueueActive({ name: 'transcode' })
  onActive(job: Job) {
    console.log(
      `Processing job ${job.id} of type ${job.name} with data ${job.data}...`,
    );
  }

  // 监听者2 完成时 监听
  @OnQueueCompleted({
    name: 'transcode',
  })
  async onQueueCompleted(jobId: number, result: any) {
    // const job = await this.que.getJob(jobId);
    await this.mockJob(6000);
    console.log('(Global) on completed: job ', jobId, ' -> result: ', result);
  }

  // 监听者2 完成时 监听(全局)
  @OnGlobalQueueCompleted({
    name: 'transcode',
  })
  async onGlobalCompleted(jobId: number, result: any) {
    await this.mockJob(6000);
    console.log('(Global) on completed: job ', jobId, ' -> result: ', result);
  }

  // 测试方法 模拟 堵塞任务
  async mockJob(time: number) {
    return new Promise((resvole, reject) => {
      setTimeout(() => resvole('1'), time);
    });
  }
}

各项文档 和参数说明

本地事件监听者全局事件监听者处理器方法签名/当触发时
@OnQueueError()@OnGlobalQueueError()handler(error: Error) - 当错误发生时,error包括触发错误
@OnQueueWaiting()@OnGlobalQueueWaiting()handler(jobId: number | string) - 一旦工作者空闲就等待执行的任务,jobId包括进入此状态的 id
@OnQueueActive()@OnGlobalQueueActive()handler(job: Job)-job任务已启动
@OnQueueStalled()@OnGlobalQueueStalled()handler(job: Job)-job任务被标记为延迟。这在时间循环崩溃或暂停时进行调试工作时是很有效的
@OnQueueProgress()@OnGlobalQueueProgress()handler(job: Job, progress: number)-job任务进程被更新为progress
@OnQueueCompleted()@OnGlobalQueueCompleted()handler(job: Job, result: any) job任务进程成功以result结束
@OnQueueFailed()@OnGlobalQueueFailed()handler(job: Job, err: Error)job任务以err原因失败
@OnQueuePaused()@OnGlobalQueuePaused()handler()队列被暂停
@OnQueueResumed()@OnGlobalQueueResumed()handler(job: Job)队列被恢复
@OnQueueCleaned()@OnGlobalQueueCleaned()handler(jobs: Job[], type: string) 旧任务从队列中被清理,job是一个清理任务数组,type是要清理的任务类型
@OnQueueDrained()@OnGlobalQueueDrained()handler()在队列处理完所有等待的任务(除非有些尚未处理的任务被延迟)时发射出
@OnQueueRemoved()@OnGlobalQueueRemoved()handler(job: Job)job任务被成功移除

压缩

注意这个 仅仅是一个 适用于 web程序的应用,在企业中 大家最好使用 nginx 来做 这类的事情,如果你仅仅是简单玩玩 可以吧这个开了

ts
// express 和 fastify 要用不一样的包 
import * as compression from 'compression';
// somewhere in your initialization file
app.use(compression());

import * as compression from 'fastify-compress';
// somewhere in your initialization file
app.register(compression);

流处理文件

这个需求 “下载文件”

ts
import { Controller, Get, StreamableFile, Response } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';

@Controller('file')
export class FileController {
  @Get('t1')
  getFile(): StreamableFile {
    // 请不要使用 @res 要不然,其它的全局错误拦截器 将连接不到你的各种error
    // 那就是代价 若一定要使用 请开启 passthrough: true 看下面的例子
    // 具体文档  https://docs.nestjs.com/controllers#library-specific-approach
    const file = createReadStream(join(process.cwd(), 'package.json'));
    return new StreamableFile(file);
  }

  @Get('t2')
  getFile2(@Response({ passthrough: true }) res): StreamableFile {
    const file = createReadStream(join(process.cwd(), 'package.json'));
    res.set({
      'Content-Type': 'application/json',
      'Content-Disposition': 'attachment; filename="package.json"',
    });
    return new StreamableFile(file);
  }
}

HTTP

Nest 很多知识点 都借鉴了 Angular 所以 Rxjs 当然是要掌握的 请看其 官方文档 Rxjs

我们直接以一个 例子来说 ,比如请求一个第三方接口 https://api.wrdan.com/hitokoto

shell
yarn add  @nestjs/axios
ts
//  若要使用 请提前注入
import { HttpModule } from '@nestjs/axios';
@Module({
  imports: [
    HttpModule.register({
      timeout: 5000, //  最大允许 超时 5s
      maxRedirects: 5, // 失败之后的 最大retry 次数
    }),
  ],
  controllers: [FileController],
  providers: [],
})
export class FileModule {}


import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { map, catchError } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
++++
  constructor(private readonly httpService: HttpService) {}

  @Get('t3')
  testAxios(): Observable<AxiosResponse<any> | any> {
    return this.httpService.get('https://api.wrdan.com/hitokoto').pipe(
      map((data) => {
        return {
          data: data.data,
          success: true,
        };
      }),
      catchError((err) => {
        return of({
          success: false,
          data: null,
        });
      }),
    );
  }
++++

Cookies & Session

先问大家一个面试题 Cookies & Session 有说明区别?

答:session的常见实现要借助cookie来发送sessionID.而Cookies 是已经实现的API 。 Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中;

Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。它是一个具体API

一般的我们认为:Session = Cookie+其它,存储于service 上

详细请参考 https://www.zhihu.com/question/19786827

Cookies

Cookie的作用 我这里就不解释了 ,相信大家都说有水平的 开发哈,直接上干货,

要想在Nest 解析 cookie 需要安装两个依赖,而且依据不同的平台 选择的包也不一样,使用方式也还是有区别的,安装官方文档中就用 Express / Fastify 两种做说明

shell
$yarn add @types/cookie-parser -D 
$yarn add cookie-parser

详细的使用介绍

ts
import * as cookieParser from 'cookie-parser';
import { cookieSecret } from './modules/cookieSessionTest/cookieSession.controller';
  // string 则直接用去加密 Array 的话 尝试使用每个依次进行 secret 加密
  // 若提供了值 那么会价在 req.signedCookies 上
  // 若没有 提供(不加密) 就在 req.cookies 上
  // app.use(cookieParser());
  app.use(cookieParser(cookieSecret));

  // 简单实用
import { Controller, Get, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import * as crypto from 'crypto';
import { Cookies } from './decorator/cookie.decorator';

export const cookieSecret = 'joney_';
@Controller('cookieSession')
export class CookieSessionController {
  @Get('t1')
  t1(@Req() request: Request) {
    // 简单获取cookie
    console.log(request.cookies);
    // 一般情况下企业级使用的时候 都会给cookie 进行加密(部分内容
    console.log(request.signedCookies);
  }

  @Get('t2')
  t2(@Res({ passthrough: true }) response: Response) {
    const valueStr = JSON.stringify({
      name: 'Joney',
      age: 24,
      address: 'CD',
      baseInfo: {
        f: 1,
      },
    });
    // 若 需要 加密请使用 signed: true
    // 默认会 去取 main中设置的 cookieSecret 拿值
    // 但.... 这有点拉胯 不建议使用 要用还请使用
    // crypto + 自定义 middleware 实现
    response.cookie('joney', valueStr, {
      signed: true,
    });
  }

  // 如果需要 更方便的获取 你可以实现一个 装饰器
  @Get('t3')
  t3(@Cookies('joney') val: any) {
    console.log(val);
  }
}

// 装饰器
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const Cookies = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const requestVal = Object.entries(request.cookies).length
      ? request.cookies
      : request.signedCookies;
    return data ? requestVal?.[data] : requestVal;
  },
);

如果使用 fastify-cookie 就使用这样的

ts
$ yarn add  fastify-cookie

// 绑定中间件
import fastifyCookie from 'fastify-cookie';
// somewhere in your initialization file
const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
);
app.register(fastifyCookie, {
  secret: 'my-secret', // for cookies signature
});


// 使用
@Get()
findAll(@Res({ passthrough: true }) response: FastifyReply) {
  response.setCookie('key', 'value')
}

Session

用来处理 session 数据的 🔧 工具,也有两个版本 express/fastify

我们先讲讲express 的

shell
yarn add express-session
yarn add @types/express-session

首先在全局中挂载

ts
import * as session from 'express-session';
// somewhere in your initialization file
app.use(
  session({
    // 注意这里参数有很多 详见文档  https://github.com/expressjs/session
    secret: 'my-secret',
    saveUninitialized: false,
    // secure:true 生产环境下 我们应该开启 https
  }),
);
// 在生产环境中,有意的默认不在服务器端提供会话存储。因为这在很多场合下会造成内存泄漏,不能扩展到单个进程,因此仅用于调试和开发环境。参见官方仓库。

然后就是读取 session

ts
@Get()
findAll(@Req() request: Request) {
  req.session.visits = req.session.visits ? req.session.visits + 1 : 1;
}

// 使用起来也相对简单
@Get('t4')
t4(@Req() request: Request) {
  log(request.session);
  return 0;
}

@Get('t5')
t5(@Session() session: Record<string, any>) {
  log(session);
  return 0;
}

以下是 基于 fastify 实现的

shell
yarn add fastify-secure-session
ts

import secureSession from 'fastify-secure-session';

// somewhere in your initialization file
const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
);
app.register(secureSession, {
  secret: 'averylogphrasebiggerthanthirtytwochars',
  salt: 'mq9hDxBVDbspDR6n',
});


// 使用
import * as secureSession from 'fastify-secure-session'
@Get()
findAll(@Req() request: FastifyRequest) {
  const visits = request.session.get('visits');
  request.session.set('visits', visits ? visits + 1 : 1);
}

@Get()
findAll(@Session() session: secureSession.Session) {
  const visits = session.get('visits');
  session.set('visits', visits ? visits + 1 : 1);
}

MVC

模板渲染(SSR ) Model-View-Controller

敬告 都2023年.... 还在用模板渲染 有点拉胯了哈,请参考 我的这篇文章 从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容,它结合了现代化的 前端框架(React) 实现了一套完成的 SSR同构 混合渲染方案,也是 Newegg 在用的方案

我们经过前面的学习,目前只需要了解 View 如何实现和使用就好了, 为了尽力和官方文档保持一致 我们仍然使用 hbs 模板引擎,当然你可以使用其他的 ,道理是一样的

hbs是基于handlebarsjs 给 express 实现的 模板引擎,因此你可以直接去看 handlebarsjs 的handlebarsjs文档 ,语法细节和大多数 模板引擎 一样

shell
yarn add hbs

建立static 资源目录和 ,template 模板目录 /-public /--js /----home.js /--css /----home.css /views /----home.hbs /----p1.hbs

home.js and css

js
.home-page {
  width: 500px;
  height: 500px;
  background-color: blanchedalmond;
}

(function () {
  console.log('this is home');
})();

home.hbs & pa.hbs

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ title }}</title>
  <link rel="stylesheet" href="static/css/home.css">
</head>
<body>
  <h2>我是Home</h2>
  <div class="home-page">
  {{ message }}
  </div>
  
  <script src="static/js/home.js"></script>
</body>
</html>

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ title }}</title>
  <link rel="stylesheet" href="static/css/home.css">
</head>
<body>
  <h2>我是P1</h2>
  <div class="home-page">
  {{ message }}
  </div>
  
  <script src="static/js/home.js"></script>
</body>
</html>

Nest框架的 设置

ts
  // 挂载 session中间件
  app.use(
    session({
      secret: seesionSecret,
      saveUninitialized: false,
    }),
  );

  // 设置模板渲染 和前端需要static 目录
  // 一般的我们在生产环境上会重命名 静态路径, 而且会另外上次到 CDN 上去
  app.useStaticAssets(join(__dirname, '..', 'public'), {
    prefix: '/static',
  });
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');


import { Controller, Get, Param, Query, Render, Res } from '@nestjs/common';
import { log } from 'console';
import { Response } from 'express';

@Controller('pages')
export class PageController {
  @Get()
  @Render('home')
  root() {
    return {
      title: 'Home',
      message: 'Hello world!',
    };
  }

  // 动态渲染
  @Get('p1')
  // 注意 你不能使用 passthrough: true
  p1(@Res() res: Response) {
    return res.render('p1', {
      title: 'P1',
      message: 'Hello world!',
    });
  }

  // 大部分情况下 我们用不到 动态渲染,大多数情况下是传递参数
  @Get('p2/:id')
  @Render('p1')
  p2(@Param('id') id: string) {
    log(id);
    return {
      title: id,
      message: 'Hello world!',
    };
  }
}

若用 fastify 请看

ts
yarn add fastify point-of-view handlebars

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
  app.useStaticAssets({
    root: join(__dirname, '..', 'public'),
    prefix: '/public/',
  });
  app.setViewEngine({
    engine: {
      handlebars: require('handlebars'),
    },
    templates: join(__dirname, '..', 'views'),
  });
  await app.listen(3000);
}
bootstrap();

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

@Controller()
export class AppController {
  @Get()
  @Render('index.hbs')
  root() {
    return { message: 'Hello world!' };
  }
}