使用nest.js开发后端web应用程序总结

记录第一次使用nest.js开发后端项目的全过程,结尾会提供一个源码小demo地址。

技术栈:
后端node服务:nest.js+mongodb
后端java服务:一个空的java服务。(非必须,这里是为了接入eureka注册中心,使用的是java sidecar实现java与node跨语言的服务间调用,后面会说到)

一、项目搭建

步骤

1、全局安装nest-cli:在终端命令行工具输入命令

npm i -g @nestjs/cli

或者

yarn global add @nestjs/cli

2、新建项目:进入一个准备存放项目文件的目录,使用命令行工具输入
nest new my-project
根据提示可以选择自己喜欢的包管理器npm或者yarn


执行新建项目命令

生成好的默认项目文件

3、启动项目:进入项目根目录,在终端输入命令

npm run start  
或者 npm run start:dev (开发模式/自带热更新)
或者 npm run start:debug (调试模式)

启动成功

地址栏输入 http://localhost:3000/ 可以看到已经启动成功

页面访问

二、nest.js项目说明

模块(module)

每个应用程序至少有一个模块,一个根模块。根模块是Nest开始安排应用程序树的地方。一般应用程序小的时候 根模块可能是应用程序中唯一的模块


app.module.ts

控制器(controller)

负责处理传入的请求并将响应返回给客户端,控制器的目的是接收应用程序的特定请求。


app.controller.ts

服务(service)

处理业务逻辑/进行数据库操作/通过构造函数注入给控制器使用


app.service.ts

三、环境变量配置与连接数据库

环境变量

一般项目都有分开发环境,测试环境和生产环境。这些环境有着对应的配置信息,比如数据库连接信息,我们需要通过环境变量来切换这些环境。此处有关环境变量配置只做开发时介绍(本地连本地数据库和本地连远程测试环境数据库)。生产环境使用pm2部署,具体配置环境变量后面单独会说明

1.使用cross-env,安装

 npm install  --s cross-env

或者

yarn add cross-env

2.package.json配置启动脚本对应的环境变量

npm run start:dev 设置环境变量为development(开发环境)并启动项目
npm run start:test 设置环境变量为test(测试环境)并启动项目


package.json

3.根目录新建env文件夹,添加三个对应环境的配置文件,每个配置文件来配置相对应的数据库信息,由于本项目的数据库信息存放在apollo配置中心,所以配置文件只配了apollo地址,项目启动之前会去配置中心取数据库配置信息,具体实现下文介绍

env文件夹

development.env

test.env

连接数据库

本项目使用的数据库为mongodb

1.从apollo配置中心拿取数据库信息

首先安装需要的依赖包

npm install --s @nestjs/typeorm typeorm mongodb (操作mongodb工具库)
npm install --s ctrip-apollo  (获取远程apollo配置中心配置信息)
npm install --s dotenv  (用于读取环境变量.env文件)

然后在src目录新建config目录,并添加三个文件:config.module.ts,config.service.ts,typeorm.service.ts

// src/config/config.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from './config.service';
import apollo = require('ctrip-apollo');

@Global()
@Module({
  providers: [
    {
      provide: ConfigService,
      useFactory: async () => {
        const { envConfig } = new ConfigService({});
        const app = apollo({
          host: envConfig['apollo.host'], // 阿波罗地址
          appId: '***', // appId
        });
        const application = app.namespace();
        application.on('change', ({ key, oldValue, newValue }) => {
          //  配置中心配置发生改变之后可以做的事情...
          console.log('change=>', key, oldValue, newValue);
        });
        const voteRet = await application.ready();
        return new ConfigService(voteRet._config);
      },
    },
  ],
  exports: [ConfigService],
})
export class ConfigModule {}
// src/config/config.service.ts
import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';

@Injectable()
export class ConfigService {
  public envConfig: { [key: string]: string };
  constructor(config: any) {
    const _path = __dirname + `/../../env/${process.env.NODE_ENV}.env`;
    const local = dotenv.parse(fs.readFileSync(_path));
    this.envConfig = Object.assign(local, config);
  }
  get(key: string): string {
    return this.envConfig[key];
  }
  getAll(): object {
    return this.envConfig;
  }
}
// src/config/typeorm.service.ts
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ConfigService } from './config.service';
import { Injectable } from '@nestjs/common';

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  constructor(private readonly configService: ConfigService) {}
  isDev = process.env.NODE_ENV == 'development';
  createTypeOrmOptions(): TypeOrmModuleOptions {
    const getCfg = key => this.configService.get(key);
    const dbName = getCfg('mongo.db');
    const username = getCfg('mongo.username');
    const password = getCfg('mongo.password');
    const db: TypeOrmModuleOptions = {
      type: 'mongodb',
      host: this.isDev ? 'localhost:27017' : getCfg('mongo.host'),
      username: this.isDev ? '' : username,
      password: this.isDev ? '' : password,
      database: dbName,
      useUnifiedTopology: true, // 当前服务器发现和监视引擎已弃用,将在将来的版本中删除。要使用新的服务器发现和监视引擎,需要配置设置为true
      entities: [__dirname + '/../**/*.entity{.ts,.js}'],
    };
    return db;
  }
}

最后在根模块导入数据库连接配置

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from './config/config.module';
import { ConfigService } from './config/config.service';
import { TypeOrmConfigService } from './config/typeorm.service';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useClass: TypeOrmConfigService,
    }),
    ConfigModule,
  ],
  controllers: [AppController],
  providers: [AppService, TypeOrmConfigService],
})
export class AppModule {}

运行 npm run build
运行 npm run start
这样我们就完成了从apollo配置中心获取到数据库信息并使用typeorm完成数据库连接

四、完善项目基础建设

异常过滤器

异常过滤器用于拦截全部的错误请求。当捕获到未处理的异常时,可以向客户端返回指定格式的友好响应。

1、在src新建common/enums目录,添加api-error-code.enum.ts文件(自定义的错误状态码)

// common/enums/api-error-code.enum.ts
export enum ApiErrorCode {
  TIMEOUT = -1, // 系统繁忙

  SUCCESS = 1, // 成功

  FAIL = 0, // 失败

  NOT_EMPTY = 20000, // 不能为空

  IS_NOT_DATE_STRING = 20001, // 时间字符串格式错误

  IS_BOOLEAN = 20002, // 应该为bool类型

  IS_NOT_OBJECTID_STRING = 20003, // 不是objectId字符串
}

2、在src新建filters目录,添加api.exception.ts,http.exception.filter.ts这两个文件

api.exception.ts 实现自定义异常错误码和信息

// src/filters/api.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';
import { ApiErrorCode } from './../common/enums/api-error-code.enum';

export class ApiException extends HttpException {
  private errorMessage: string;
  private errorCode: ApiErrorCode;

  constructor(
    errorMessage: string,
    errorCode: ApiErrorCode,
    statusCode: HttpStatus,
  ) {
    super(errorMessage, statusCode);

    this.errorMessage = errorMessage;
    this.errorCode = errorCode;
  }

  getErrorCode(): ApiErrorCode {
    return this.errorCode;
  }

  getErrorMessage(): string {
    return this.errorMessage;
  }
}

http.exception.filter.ts 能够响应自定义异常和内置异常信息

// http.exception.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  Logger,
} from '@nestjs/common';
import { ApiException } from './api-exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';

@Catch(HttpException)
export class HttpExceptionFilter<T> implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    response.header('Content-Type', 'application/json;charset=utf-8');
    const ret = {
      resultMsg: null,
      resultCode: null,
    };
    response.status(200); // 设置响应状态码为200
    if (exception instanceof ApiException) {
      ret.resultCode = exception.getErrorCode();
      ret.resultMsg = exception.getErrorMessage();
    } else {
      ret.resultCode = ApiErrorCode.FAIL;
      ret.resultMsg = exception.message;
    }
    Logger.log(`code:${ret.resultCode},contenx:${ret.resultMsg}`);
    response.send(ret);
  }
}

3、修改main.ts,全局注册异常过滤器

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

例如:当访问一个不存在的getList请求
可以看到加了异常过滤器之后,返回了我们自定义的响应格式:

 const ret = {
     resultMsg: null,
     resultCode: null,
   };
异常过滤器响应的格式
默认响应的格式

拦截器转换

这里使用拦截器转换所有成功请求的响应结果数据格式,拦截器还有许多功能可以参考其他资料

1、在src目录新建interceptor文件夹,并添加transform.interceptor.ts文件

这里的noInterceptor有用到反射器获取自定义装饰器的设置的值用于区分是否需要进行拦截器转换操作,因为有些请求是不需要转换的,关于自定义装饰器下文会具体说明

// src/interceptor/transform.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import { Reflector } from '@nestjs/core';

interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>> {
  constructor(private readonly reflector: Reflector) {}
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
    return next.handle().pipe(
      map(data => {
        const noTransfInterceptor = this.reflector.get<boolean>(
          'noTransfInterceptor',
          context.getHandler(),
        );
        if (noTransfInterceptor) return data;
        return {
          success: true,
          resultCode: ApiErrorCode.SUCCESS,
          data,
        };
      }),
    );
  }
}

2、修改main.ts,全局注册转换拦截器

// src/main.ts
import { NestFactory,Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http.exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
  await app.listen(3000);
}
bootstrap();

这样我们所有请求最后输出的数据都能按指定的格式响应给客户端了

使用截器转换输出

原数据输出

Swagger文档

一般后端开发的接口都会提供对应的接口文档,方便查阅与调试。这里我们使用了@nestjs/swagger来实现

安装:

npm i --s @nestjs/swagger
npm i --s swagger-ui-express

1、在src新建lifecycle文件夹,添加completed.ts文件

import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

export default function(app) {
  const env = process.env.NODE_ENV;
  global.isDev = ['development', 'test'].find(item => item === env)
    ? true
    : false;

  // 生成接口文档
  const createSwagger = app => {
    const options = new DocumentBuilder()
      .setTitle('vote service nest doc')
      .setDescription('Api description')
      .setVersion('1.0')
      .build();
    const document = SwaggerModule.createDocument(app, options);
    SwaggerModule.setup('api-docs', app, document);
  };
  global.isDev && createSwagger(app); // 在开发环境和测试环境就生成api文档
}

2、在首页引入
修改main.ts

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http.exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import completed from './lifecycle/completed';
import './global/global-lib-d';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
  completed(app);
  await app.listen(3000);
}
bootstrap();

@nestjs/swagger有着丰富的配置信息,具体可以参考其他网上资料

  // 创建一个投票
  @Post('createVote')
  @ApiHeader({
    name: 'access-token',
    required: true,
    description: '本次请求请带上token',
    example: 'b46f9a99651c4d13b126f353548766b8',
  })
  async createVote(....) {
    ....
  }

访问:http://localhost:3000/api-docs

接口文档

管道验证

对于后端项目来说为了减少客户端请求造成的未知异常,验证请求携带的参数是很有必要的。nest.js提供管道来对参数进行校验,验证通过执行后面的逻辑,验证不通过就抛出指定错误异常。

首先安装好以下依赖
npm install --s class-transformer
npm install --s class-validator

1、在src下面新建pipe文件夹,并添加api.params.validation.pipe.ts文件

// src/pipe/api.params.validation.pipe.ts
import {
  ArgumentMetadata,
  PipeTransform,
  Injectable,
  HttpStatus,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { ApiException } from 'src/filters/api.exception';

@Injectable()
export class ApiParamsValidationPipe implements PipeTransform {
  async transform(value: any, metadata: ArgumentMetadata) {
    const { metatype } = metadata;
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      // 获取到第一个没有通过验证的错误对象
      const error = errors.shift();
      const constraints = error.constraints;
      const contexts = error.contexts || {};
      // 将未通过验证的字段的错误信息和状态码,以ApiException的形式抛给我们的全局异常过滤器
      if (constraints)
        for (const key in constraints) {
          throw new ApiException(
            constraints[key],
            (contexts[key] && contexts[key].errorCode) || 0,
            HttpStatus.BAD_REQUEST,
          );
        }
      else {
        // 使用对象验证器无法验证对象类型
        throw new ApiException(
          `${error.property} error`,
          0,
          HttpStatus.BAD_REQUEST,
        );
      }
    }
    return Object.assign(value, object);
  }

  private toValidate(metatype): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.find(type => metatype === type);
  }
}

2、修改main.ts,全局注册管道验证

// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http.exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { ApiParamsValidationPipe } from './pipe/api.params.validation.pipe';
import completed from './lifecycle/completed';
import './global/global-lib-d';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
  app.useGlobalPipes(new ApiParamsValidationPipe());
  completed(app);
  await app.listen(3000);
}
bootstrap();

使用守卫

nest.js提供守卫处理访问授权问题,包括访问令牌token,角色权限等。
我们这里使用守卫进行token校验

1、在src目录新建guard文件夹,并添加auth.guard.ts
当请求头没有携带access-token,我们就抛出自定义异常(缺少访问令牌),标识当前请求没权限无法不通过
这里我们还定义了一个通过反射器获取noToken的变量,作用是通过自定义装饰器(后面会单独说明)标识这个请求是否需要token校验,因为有些请求我们是需要放开权限不需要token也支持访问

// src/guard/auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpStatus,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { ApiException } from 'src/filters/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';

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

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = request.headers['access-token'];
    const noToken = this.reflector.get<boolean>(
      'noToken',
      context.getHandler(),
    );
    if (!noToken && !token) {
      throw new ApiException(
        '缺少访问令牌',
        ApiErrorCode.FAIL,
        HttpStatus.FORBIDDEN,
      );
    }
    global.token = token;
    return true;
  }
}

2、全局注册守卫
修改main.ts

// main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http.exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { ApiParamsValidationPipe } from './pipe/api.params.validation.pipe';
import completed from './lifecycle/completed';
import './global/global-lib-d';
import { AuthGuard } from './guard/auth.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
  app.useGlobalPipes(new ApiParamsValidationPipe());
  app.useGlobalGuards(new AuthGuard(new Reflector()));

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

我们在swagger上进行验证,启用守卫后, 请求头不带token,就会提示“缺少访问令牌”,携带则访问成功


缺少访问令牌,访问失败

携带访问令牌,访问成功

中间件

中间件是在路由处理程序 之前 调用的函数。 中间件函数可以访问请求和响应对象,以及应用程序请求响应周期中的 next() 中间件函数。

中间件早于守卫执行,守卫早于拦截器或者管道执行

1、全局中间件
启用全局中间件所有请求都会进入全局中间件
在src新建middlewares文件夹,并添加global.middleware.ts
这里写的是函数中间件

// src/middlewares/global.middleware.ts
export function GlobalMidWare(req, res, next) {
  // 可以做一些事情...
  console.log('come in....');
  next();
}

修改main.ts

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http.exception.filter';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { ApiParamsValidationPipe } from './pipe/api.params.validation.pipe';
import completed from './lifecycle/completed';
import './global/global-lib-d';
import { AuthGuard } from './guard/auth.guard';
import { GlobalMidWare } from './middlewares/global.middleware';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.use(GlobalMidWare);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));
  app.useGlobalPipes(new ApiParamsValidationPipe());
  app.useGlobalGuards(new AuthGuard(new Reflector()));

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

2、自定义中间件
在src中的middlewares文件夹添加common.middleware.ts

// src/middlewares/common.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class CommonMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    console.log('路由==>', req.baseUrl);
    next();
  }
}

修改app.module.ts
应用中间件forRoutes可以指定路由或者使用路由通配符或者指定中间件消费者,具体查阅资料

// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from './config/config.module';
import { ConfigService } from './config/config.service';
import { TypeOrmConfigService } from './config/typeorm.service';
import { CommonMiddleware } from './middlewares/common.middleware';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useClass: TypeOrmConfigService,
    }),
    ConfigModule,
  ],
  controllers: [AppController],
  providers: [AppService, TypeOrmConfigService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
     consumer.apply(CommonMiddleware).forRoutes("/");
  }
}

五、使用nest.js进行业务开发

DTO(数据传输对象)

我们在使用管道来验证数据格式使用到了dto,一个展示层与服务层数据交互的对象。
1、创建一个dto

 import {
  IsNotEmpty,
  IsBoolean,
  IsEnum,
  MinLength,
  MaxLength,
  ValidateNested,
  Length,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
 
export class VotdLibDto {
  readonly id: number;

  @ApiProperty({
    description: '投票标题',
    example: '2020年美国总统大选投票',
  })
  @IsNotEmpty({
    message: '投票标题不能为空',
    context: { errorCode: ApiErrorCode.NOT_EMPTY },
  })
  readonly title: string;
  ...
}

2、客户端请求的voteLib参数类型为VotdLibDto对象,因为我们已经对dto添加了数据校验,所以验证不通过的会被异常过滤器返回给客户端

import { VotdLibDto } from './dto/voteLib.dto';
....
  // 创建一个投票
  @Post('createVote')
  @HttpCode(200)
  @ApiHeader({
    name: 'access-token',
    required: true,
    description: '本次请求请带上token',
    example: '576f9a99651c4d13b124f353048766b8',
  })
  async createVote(@Body() voteLib: VotdLibDto, @Req() req) {
    const referer = req.header('referer');
    return this.voteService.createVote(voteLib, referer);
  }

例如:当请求参数title为空时,后台校验不通过就会把我们定义的异常信息响给客户端


在dto中对数据进行校验
请求参数
响应结果

自定义装饰器

装饰器(java中也叫注解):ES2016 的装饰器是一个可以将目标对象,名称和属性描述符作为参数的返回函数的表达式。你可以通过装饰器前缀 @ 来使用它,并将其放置在您想要装饰的最顶端。装饰器可以被定义为一个类或是属性。

本项目用到了两种自定义装饰器

1、参数验证装饰器
除了使用class-validator自带的参数验证例如:IsNotEmpty、IsBoolean...我们想写自己的验证器怎么办?

 import {IsNotEmpty,IsBoolean } from 'class-validator';

 ....

 @IsNotEmpty({
    message: '投票标题不能为空',
    context: { errorCode: ApiErrorCode.NOT_EMPTY },
  })
  readonly title: string;
 @IsBoolean({
    message: '是否多选 只能为boolean类型',
    context: { errorCode: ApiErrorCode.IS_BOOLEAN },
  })
  readonly multiple: boolean;
 ....

本项目中我们对客户端传递的时间参数做校验,满足我们指定的时间格式才算通过
在src新建decorator文件夹,并添加validtor.ts文件

// src/decorator/validtor.ts
import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

import moment = require('moment');

// 自定义装饰器
function validateHandler(
  property: string,
  validationOptions?: ValidationOptions,
  customOption?: any,
) {
  return function(object: Record<string, any>, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      constraints: [property],
      options: validationOptions,
      name: property,
      ...customOption,
    });
  };
}

// 处理时间格式
export function IsLocalDate(
  property: string,
  validationOptions?: ValidationOptions,
) {
  return validateHandler(property, validationOptions, {
    validator: {
      validate(value: any, args: ValidationArguments) {
        const [relatedPropertyName] = args.constraints;
        // 1.不能转成时间格式直接返回false
        // 2.能够转成时间格式则把输入的字符串格式转化为时间格式
        const formats = [
          'YYYY-MM-DD',
          'YYYY-MM-DD LT',
          'YYYY-MM-DD h:mm:ss A',
          'YYYY-MM-DD HH:mm:ss',
          'YYYY-MM-DD HH:mm',
          'YYYY/MM/DD h:mm:ss A',
          'YYYY/MM/DD HH:mm:ss',
          'YYYY/MM/DD HH:mm',
        ];
        const isDate = moment(value, formats, true).isValid();
        if (isDate) {
          (args.object as any)[relatedPropertyName] = new Date(value);
          return true;
        }
        return false;
      },
    },
  });
}

IsLocalDate是我们创建的自定义参数装饰器,当时间字符串格式满足以下这几种,那么就验证通过,否则不通过

const formats = [
          'YYYY-MM-DD',
          'YYYY-MM-DD LT',
          'YYYY-MM-DD h:mm:ss A',
          'YYYY-MM-DD HH:mm:ss',
          'YYYY-MM-DD HH:mm',
          'YYYY/MM/DD h:mm:ss A',
          'YYYY/MM/DD HH:mm:ss',
          'YYYY/MM/DD HH:mm',
        ];

在validate函数中我们可以写自己的验证逻辑,最终返回的true或者false表示这个验证是否通过
最后我们在dto中验证"endTime"字段的时候使用这个IsLocalDate自定义装饰器

import {IsNotEmpty,IsBoolean } from 'class-validator';
import { IsLocalDate } from 'src/decorator/validtor';

 ....

@IsLocalDate('endTime', {
    message: '截止时间格式错误',
    context: { errorCode: ApiErrorCode.IS_NOT_DATE_STRING },
  })
readonly endTime: Date;

2、路由装饰器
上文拦截器转换用了自定义装饰器NoTransfInterceptor,为了让给某些特殊的请求返回原始结果而不被拦截器转换
在src下面decorator文件夹内添加request.decort.ts文件

// src/decorator/request.decort.ts
import { SetMetadata } from '@nestjs/common';

/**
 * 去掉拦截器转换输出原始值
 */
export const NoTransfInterceptor  = () => SetMetadata('NoTransfInterceptor', true);

transform.interceptor.ts
这里就是通过反射器拿到我们标识免拦截转换的noInterceptor,通过noInterceptor我们就可以通过判断是否需要拦截转换了

// src/interceptor/transform.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import { Reflector } from '@nestjs/core';

interface Response<T> {
  data: T;
}
// 拦截器 统一处理请求成功返回的数据
@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>> {
  constructor(private readonly reflector: Reflector) {}
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
    return next.handle().pipe(
      map(data => {
        const noInterceptor = this.reflector.get<boolean>(
          'noInterceptor',
          context.getHandler(),
        );
        if (noInterceptor) return data;
        return {
          success: true,
          resultCode: ApiErrorCode.SUCCESS,
          data,
        };
      }),
    );
  }
}

最后直接使用

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { NoTransfInterceptor } from './decorator/request.decort';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
   ...
  @Get('/health')
  @NoTransfInterceptor()
  health(): object {
    return {
      status: 'UP',
    };
  }
}

另外一个自定义装饰器NoToken就是守卫使用的免token访问
修改src下面decorator文件夹内的request.decort.ts文件

// src/decorator/request.decort.ts
import { SetMetadata } from '@nestjs/common';

/**
 * 去掉拦截器转换输出原始值
 */
export const NoTransfInterceptor = () =>
  SetMetadata('NoTransfInterceptor', true);

/**
 * 免token访问
 */
export const NoToken = () => SetMetadata('noToken', true);

auth.guard.ts 通过反射器获取到自定义装饰器标识的noToken,通过noToken就可以判断是否需要进行守卫token验证

// src/guard/auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpStatus,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { ApiException } from 'src/filters/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';

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

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = request.headers['access-token'];
    const noToken = this.reflector.get<boolean>(
      'noToken',
      context.getHandler(),
    );
    if (!noToken && !token) {
      throw new ApiException(
        '缺少访问令牌',
        ApiErrorCode.FAIL,
        HttpStatus.FORBIDDEN,
      );
    }
    global.token = token;
    return true;
  }
}

使用

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { NoTransfInterceptor, NoToken } from './decorator/request.decort';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
   ...
  @Get('/health')
  @NoToken()
  @NoTransfInterceptor()
  health(): object {
    return {
      status: 'UP',
    };
  }
}

此处主要介绍如何在nest.js如何使用自定义装饰器,你的装饰器可以功能很强大

Entity(实体对象)

我们使用typeorm将实体对象映射到数据库中

1、创建一个实体对象

import { Entity, Column, ObjectIdColumn, CreateDateColumn } from 'typeorm';
import { Creator } from './creator.entity';

@Entity()
export class Voted {
  @ObjectIdColumn()
  public id: number;

  @Column()
  public voteLibId: string; // 投票主题id

  @Column()
  public creator: Creator; // 投票人

  @Column()
  public optionIds: number[]; // 投票选项

  @CreateDateColumn({ comment: '创建时间' })
  createTime: string;
}

2、我们在服务层进行业务逻辑处理和数据库操作
votedRepty将会提供对数据库的增删改查操作

import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Voted } from './entity/voted.entity';
import { VotedDto } from './dto/voted.dto';

@Injectable()
export class VoteService {
  constructor(
    @InjectRepository(Voted)
    private readonly votedRepty: Repository<Voted>,
  ) {}

  /**
   * 投票满足:1.存在这个投票主题id,2.投票未截止,3.未投票
   * @param voted 进行投票需要的参数
   * @param referer 来源url
   */
  async submitVote(voted: VotedDto, referer: string) {
   ......
   const { voteLibId, creator, optionIds } = voted;
    let ret = await this.votedRepty.save{(
          voteLibId,
          creator,
          optionIds,
        });
  }
}

使用typeORM对mongodb进行查询操作

1、使用id直接查询单条记录

const voteLibId='*****'
const thisOne = await this.voteLibRepty.findOne(voteLibId);

2、使用带where条件查询

 const thisVoted = await this.votedRepty.findOne({
        where: {
          voteLibId: '*****',
          'creator.userId': '*****',
        },
      });

3、使用id集合作为条件并将结果order by排序查询
这里查询出来的list的voteLibId为字符串id需要使用mongodb的ObjectId进行转换

  import mongodb = require('mongodb');
  ....
  const list = await this.voteLibSendStateRepty.find({ sceneId });
  const ObjectId = mongodb.ObjectId;
  const ids = list && list.map(item => ObjectId(item.voteLibId));
  const voteLibList = await this.voteLibRepty.findByIds(ids, {
      order: {
        createTime: 'DESC',
      },
    });

部署

node服务部署

我们使用pm2进行部署
1、安装

npm install -g pm2

2、在项目根目录新建process.config.js
这里我们添加了三个环境变量

module.exports = {
  apps: [
    {
      name: 'node-vote',
      script: 'dist/main.js',
      env: {
        NODE_ENV: 'development',
      },
      env_test: {
        NODE_ENV: 'test',
      },
      env_production: {
        NODE_ENV: 'production',
      },
    },
  ],
};

3、修改package.json,新增以下命令

 "pm2:test": "pm2 start process.config.js --env test",
 "pm2:development": "pm2 start process.config.js --env development",
 "pm2:production": "pm2 start process.config.js --env production",

这样我们就可以部署不同环境的node服务,此处使用pm2部署纯node服务,具体生产部署可能是从Jenkins拉取代码实现自动化部署,情况会复杂一些,运维部署会更专业

运行命令:
npm run pm2:test 部署测试环境
npm run pm2:development 部署开发环境
npm run pm2:production 部署生产环境

// 其他常用命令
pm2 start  dist/main.js --name node-vote(启动进程)

pm2 list(列出所有进程)

pm2 stop node-vote ( 结束进程 )

pm2 stop all( 结束所有进程)

pm2 delete node-vote ( 删除进程)

pm2 delete all(删除所有进程)

关于sidecar

有些公司使用了springcloud作为微服务框架 ,为了让其他语言的服务(Nodejs,Python,Php...)能够接入到springcloud框架中,所以我们使用了sidecar,原理如下,摘抄自网络

Spring Cloud Netflix Sidecar 包含一个简单的http api来获取给定服务的所有实例(即主机和端口)。然后可以通过从Eureka获取其路由条目的嵌入式Zuul代理来代理服务调用。可以通过主机查找或通过Zuul代理访问Spring Cloud Config服务器。但是第三方程序必须执行健康检查,以便Sidecar可以向应用程序启动或关闭时向eureka报告。

健康检查

我们需要定义一个接口给eureka注册中心进行定时的健康检查,status为up则表示我们的node服务是正常运行

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { NoTransfInterceptor, NoToken } from './decorator/request.decort';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  ...

  @Get('/health')
  @NoToken()
  @NoTransfInterceptor()
  health(): object {
    return {
      status: 'UP',
    };
  }
}

Java服务

一个简单的空壳java服务,需要用到sidecar组件
1、引入依赖包

// pom.xml
...
 <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-netflix-sidecar</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
...

2、配置端口和访问node服务的地址

// bootstrap.properties
server.port=***
spring.application.name=***
eureka.client.serviceUrl.defaultZone=http://***.***.*.***/eureka

sidecar.port=***
sidecar.health-uri=http://localhost:${sidecar.port}/health

3、在服务启动的入口引用注解@EnableSidecar标识启用sidecar

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.SpringCloudApplication;
import org.springframework.cloud.netflix.sidecar.EnableSidecar;


@SpringCloudApplication
@EnableSidecar
public class App {

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

启动java sidecar空服务,在注册中心我们已经看到服务跑起来了


java空服务

最后启动node服务
我们就完成node服务集成到注册中心了,客户端的请求带上服务名会走到sidecar,sidecar代理到node服务。这样也可以实现java服务与node服务间调用和调用链跟踪。

源码

源码存放github:https://github.com/one-coder6/nest-little.git

参考资料:

nest.js文档:https://docs.nestjs.cn/6/introduction
nest.js系列教程:https://www.jianshu.com/p/622ede9a2d81
pm2官方文档:https://pm2.keymetrics.io/docs/usage/application-declaration/
sidecar:https://cloud.spring.io/spring-cloud-static/spring-cloud-netflix/1.3.5.RELEASE/multi/multi__polyglot_support_with_sidecar.htmlhttps://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-netflix-sidecar

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342