Skip to content

搭建一个 NestJS + TypeORM + TypeScript + Webpack + PM2 + ESLint + Prettier 的工程

本工程的Github地址

编写此笔记时所使用的NestJS版本为10.0.0

相关文档

项目初始化

先全局安装官方的脚手架,然后通过命令nest new创建项目

sh
npm i -g @nestjs/cli
nest new nestjs-starter

修改tsconfig.json,增加一项esModuleInterop

json
{
  "compilerOptions": {
    // ...
    "esModuleInterop": true
  }
}

通过官方脚手架创建的项目已经帮我们集成好 ESLint 和 Prettier 了,我们只需根据自己的喜好修改配置即可

查看
js
module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: 'tsconfig.json',
    tsconfigRootDir: __dirname,
    sourceType: 'module',
  },
  plugins: ['@typescript-eslint/eslint-plugin', 'prettier'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'prettier',
    'plugin:prettier/recommended',
  ],
  root: true,
  env: {
    node: true,
    jest: true,
  },
  ignorePatterns: ['.eslintrc.js'],
  rules: {
    'prettier/prettier': 'error',
    complexity: ['error', 10],
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
  },
}
json
{
  "$schema": "https://json.schemastore.org/prettierrc",
  "semi": false,
  "tabWidth": 2,
  "printWidth": 120,
  "singleQuote": true,
  "trailingComma": "es5"
}

Monorepo

将项目改为monorepo模式,详细的文档看这里

sh
nest g app api

执行这个命令之后,目录结构会变成类似下面这样

原先脚手架创建的标准应用会被收纳到apps/nestjs-starter目录下,但我现在要把它移除,改以apps/api作为主应用

  • 删除apps/nestjs-starter目录
sh
rm -rf apps/nestjs-starter
  • 修改nest-cli.json
json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "apps/api/src", 
  "compilerOptions": {
    "deleteOutDir": true,
    "webpack": true,
    "tsConfigPath": "apps/api/tsconfig.app.json"
  },
  "monorepo": true,
  "root": "apps/api", 
  "projects": {
    // ...
    "api": {
      "type": "application",
      "root": "apps/api",
      "entryFile": "main",
      "sourceRoot": "apps/api/src",
      "compilerOptions": {
        "tsConfigPath": "apps/api/tsconfig.app.json"
      }
    }
  }
}
  • 修改package.json
json
{
  // ...
  "scripts": {
    // ...
    "start:prod": "node dist/apps/api/main", 
    "test:e2e": "jest --config ./apps/api/test/jest-e2e.json"
  }
}

环境变量

详细文档看这里

sh
pnpm add @nestjs/config joi
pnpm add -D cross-env

新建.env.env.local环境变量文件,填入自己的环境变量;修改apps/api/src/app.module.ts,在imports中新增ConfigModule配置项

ts
import { ConfigModule } from '@nestjs/config'
import Joi from 'joi'
const envFilePath =
  process.env.NODE_ENV === 'production'
    ? ['.env.production.local', '.env.production']
    : [`.env.${process.env.NODE_ENV}.local`, '.env.local', '.env']
@Module({
  // ...
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath,
      validationSchema: Joi.object({
        NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
        APP_PORT: Joi.number().default(3000),
        JWT_SECRET: Joi.string().required(),
        JWT_EXPIRES_IN: Joi.string().default('7d'),
      }),
    }),
  ],
})

⚡ 说明

其中joi包可以用来校验环境变量的值和类型,相关文档看这里

配置PM2

sh
pnpm add -D pm2
touch ecosystem.config.js
touch .env.production
js
const { name } = require('./package.json')
const path = require('path')

module.exports = {
  apps: [
    {
      name, // 应用程序名称
      cwd: './dist', // 启动应用程序的目录
      script: path.resolve(__dirname, './dist/apps/api/main.js'), // 启动脚本路径
      instances: require('os').cpus().length, // 要启动的应用实例数量
      max_memory_restart: '1G', // 超过指定的内存量,应用程序将重新启动
      autorestart: true, // 自动重启
      watch: true, // 启用监视和重启功能
      // 环境变量
      env: {
        NODE_ENV: 'production',
      },
    },
  ],
}

修改package.json

json
{
  // ...
  "scripts": {
    // ...
    "build": "nest build && cp .env.production dist/",
    "start:prod": "cross-env NODE_ENV=production node dist/apps/api/main",
    "deploy": "pm2 start", 
    "deploy:stop": "pm2 stop all"
  }
}

Redis服务

sh
pnpm add ioredis @liaoliaots/nestjs-redis

@liaoliaots/nestjs-redis 这个包的使用文档看这里

修改apps/api/src/app.module.ts,在imports中新增RedisModule配置项

ts
import { ConfigModule, ConfigService } from '@nestjs/config'
import { RedisModule } from '@liaoliaots/nestjs-redis'
@Module({
  imports:[
    // ...
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        // ...
        REDIS_PORT: Joi.number().default(6379),
        REDIS_HOST: Joi.string().default('127.0.0.1'),
        REDIS_USER: Joi.string().default('root'),
        REDIS_PWD: Joi.string().required(),
      }),
    }),
    RedisModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        return {
          config: {
            host: config.get('REDIS_HOST'),
            port: config.get('REDIS_PORT'),
            username: config.get('REDIS_USER'),
            password: config.get('REDIS_PWD'),
          },
        }
      },
    }),
  ]
})

日志

sh
pnpm add winston nest-winston winston-daily-rotate-file

修改apps/api/src/app.module.ts,在imports中新增WinstonModule配置项

ts
import winston from 'winston'
import { WinstonModule } from 'nest-winston'
import 'winston-daily-rotate-file'
@Module({
  // ...
  imports: [
    // ...
    WinstonModule.forRoot({
      exitOnError: false, // 出现 uncaughtException 时是否 process.exit
      transports: [
        new winston.transports.DailyRotateFile({
          silent: process.env.NODE_ENV !== 'production',
          dirname: 'logs/api', // 日志保存的目录
          filename: '%DATE%.log', // 日志名称,占位符 %DATE% 取值为 datePattern 值
          datePattern: 'YYYY-MM-DD', // 日志轮换的频率,此处表示每天
          zippedArchive: true, // 是否通过压缩的方式归档被轮换的日志文件
          maxSize: '20m', // 设置日志文件的最大大小,m 表示 mb
          maxFiles: '14d', // 保留日志文件的最大天数,此处表示自动删除超过 14 天的日志文件
          // 记录时添加时间戳信息
          format: winston.format.combine(
            winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
            winston.format.json()
          ),
        }),
      ],
    }),
  ],
})

项目公共库

详细的文档看这里

sh
nest g library common

这里提示可以设置自定义的路径别名,我这里设置为@libs,不填的话默认是@app

执行命令后会在根目录生成一个libs文件夹,这里一般用来写项目的公共函数和服务。一个项目可以有多个公共库

工具类

新建libs/common/src/utils/index.ts

查看
ts
import { Request } from 'express'
const singletonEnforcer = Symbol()
class Utils {
  private static _instance: Utils
  constructor(enforcer: any) {
    if (enforcer !== singletonEnforcer) {
      throw new Error('Cannot initialize Utils single instance')
    }
  }
  static get instance() {
    // 如果已经存在实例则直接返回, 否则实例化后返回
    this._instance || (this._instance = new Utils(singletonEnforcer))
    return this._instance
  }
  /** 获取请求信息 */
  getReqForLogger(req: Request): Record<string, any> {
    const { url, headers, method, body, params, query, connection } = req
    const xRealIp = headers['X-Real-IP']
    const xForwardedFor = headers['X-Forwarded-For']
    const { ip: cIp } = req
    const { remoteAddress } = connection || {}
    const ip = xRealIp || xForwardedFor || cIp || remoteAddress
    return {
      url,
      host: headers.host,
      ip,
      method,
      body,
      params,
      query,
    }
  }
}
export default Utils.instance

自定义异常

新建libs/common/src/exceptions/api-exception.ts

ts
import { HttpException, HttpStatus } from '@nestjs/common'
export class ApiException extends HttpException {
  /**
   * @param msg 业务消息
   * @param code 业务码
   */
  constructor(msg = '', code = 'E0001') {
    super({ code, message: msg, success: false }, HttpStatus.OK)
  }
}

类型接口

common库中创建一个类型接口,用来描述用户请求的携带数据

sh
nest g interface user-request interface -p common --flat
ts
import { Request } from '@nestjs/common'
export interface UserRequest extends Request {
  user: {
    id: number | string
    username: string
    role: number
    avatar: string
    [key: string]: any
  }
}

装饰器

common库中创建用户装饰器和开放接口装饰器

sh
nest g decorator user decorators -p common --flat --no-spec
nest g decorator public-api decorators -p common --flat --no-spec
ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
/** 获取请求中携带的用户信息 */
export const User = createParamDecorator((data: string, ctx: ExecutionContext) => {
  const request = ctx.switchToHttp().getRequest()
  const user = request.user
  return data ? user && user[data] : user
})
ts
import { SetMetadata } from '@nestjs/common'
export const IS_PUBLIC_API = Symbol('IS_PUBLIC_API')
/** 开放接口装饰器 */
export const PublicApi = () => SetMetadata(IS_PUBLIC_API, true)

生命周期

中间件

NestJS中的中间件(Middleware)是一种用于处理HTTP请求的函数,它可以在请求到达控制器之前或之后执行一些操作。中间件可以用于实现身份验证、日志记录、错误处理等功能。在NestJS中,中间件可以是全局的,也可以是局部的

全局中间件

common库中创建日志中间件和接口维护中间件

sh
nest g mi logger middlewares -p common --flat --no-spec
nest g mi maint middlewares -p common --flat --no-spec
ts
import { Inject, Injectable, NestMiddleware } from '@nestjs/common'
import { NextFunction, Request, Response } from 'express'
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
import { Logger } from 'winston'
import Utils from '../utils'

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
  use(req: Request, res: Response, next: NextFunction) {
    this.logger.info('route', { request: Utils.getReqForLogger(req) })
    next()
  }
}
ts
import { Injectable, NestMiddleware } from '@nestjs/common'
import { NextFunction, Request, Response } from 'express'
import { InjectRedis } from '@liaoliaots/nestjs-redis'
import Redis from 'ioredis'
import { ApiException } from '../exceptions/api-exception'
interface IMaintenance {
  type: 'ALL' | 'PART' // 维护类型
  message: string // 维护信息
  list?: string[] // type 为 PART 时指定的维护接口
}
const REDIS_MAINT_KEY = '@@REDIS_MAINT_KEY'
@Injectable()
export class MaintMiddleware implements NestMiddleware {
  constructor(@InjectRedis() private readonly redis: Redis) {}
  async use(req: Request, res: Response, next: NextFunction) {
    const { url, method } = req
    const currentApi = `${method.toLowerCase()}:${url}`
    const maintData: IMaintenance | null = JSON.parse(await this.redis.get(REDIS_MAINT_KEY))
    if (maintData) {
      switch (maintData.type) {
        case 'ALL':
          throw new ApiException(maintData.message)
        case 'PART':
          if (maintData?.list.includes(currentApi)) throw new ApiException(maintData.message)
        default:
          break
      }
    }
    next()
  }
}

修改apps/api/src/app.module.ts,应用全局中间件

ts
// ...
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'
import { LoggerMiddleware, MaintMiddleware } from '@libs/common'
@Module({
  // ...
})

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(MaintMiddleware, LoggerMiddleware)
      .exclude({ path: 'swagger/(.*)', method: RequestMethod.ALL })
      .forRoutes({ path: '*', method: RequestMethod.ALL })
  }
}

守卫

NestJS中的守卫(Guard)是一种用于保护路由的机制,它可以在请求到达控制器之前或之后执行一些操作。守卫可以用于实现身份验证、权限控制、缓存等功能。在NestJS中,守卫可以是全局的,也可以是局部的

全局守卫

api应用中创建一个守卫

sh
nest g guard jwt-auth guards -p api --flat --no-spec
ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { IS_PUBLIC_API } from '@libs/common'
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 如果是公共开放接口,则直接放行
    if (this.reflector.get(IS_PUBLIC_API, context.getHandler())) return true
    return true
  }
}

修改apps/api/src/app.module.ts,应用全局守卫

ts
// ...
import { APP_GUARD } from '@nestjs/core'
import { JwtAuthGuard } from './guards/jwt-auth.guard'
@Module({
  // ...
  providers: [
    // ...
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})

拦截器

NestJS中的拦截器(Interceptor)是一种用于处理HTTP请求和响应的函数,它可以在请求到达控制器之前或之后执行一些操作。拦截器可以用于实现日志记录、错误处理、数据转换等功能。在NestJS中,拦截器可以是全局的,也可以是局部的

全局拦截器

common库中创建一个拦截器

sh
nest g interceptor transform interceptors -p common --flat --no-spec
ts
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common'
import { Observable, map } from 'rxjs'
import { Request } from 'express'
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
import { Logger } from 'winston'
import Utils from '../utils'

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest<Request>()
    const now = Date.now()
    return next.handle().pipe(
      map((data) => {
        console.log(`本次请求处理耗时 ${Date.now() - now}ms`)
        this.logger.info('response', { data, request: Utils.getReqForLogger(req) })
        return { code: '200', message: 'ok', success: true, data }
      })
    )
  }
}

修改apps/api/src/app.module.ts,应用全局拦截器

ts
// ...
import { APP_INTERCEPTOR } from '@nestjs/core'
import { TransformInterceptor } from '@libs/common'
@Module({
  // ...
  providers: [
    // ...
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor,
    },
  ],
})

过滤器

NestJS中的过滤器(Filter)是一种用于处理HTTP请求和响应的函数,它可以在请求到达控制器之前或之后执行一些操作。过滤器可以用于实现数据转换、错误处理、响应格式化等功能。在NestJS中,过滤器可以是全局的,也可以是局部的

全局过滤器

common库中创建一个异常过滤器

sh
nest g filter unify-exception filters -p common --flat --no-spec
查看unify-exception.filter.ts
ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
  Inject,
} from '@nestjs/common'
import { Request, Response } from 'express'
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
import { Logger } from 'winston'
import dayjs from 'dayjs'
import Utils from '../utils'

@Catch()
export class UnifyExceptionFilter implements ExceptionFilter {
  constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const request = ctx.getRequest<Request>()
    const response = ctx.getResponse<Response>()
    const status =
      exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
    const data = {
      success: false,
      code: void 0,
      message: status >= 500 ? exception.message ?? 'Server Error' : 'Client Error',
      url: request.url,
      timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
    }
    if (exception instanceof HttpException) {
      const res: any = exception.getResponse()
      if (Object.prototype.toString.call(res) === '[object Object]') {
        res.message && (data.message = res.message)
        res.code && (data.code = res.code)
      }
    }
    this.logger.error('exception', {
      status,
      request: Utils.getReqForLogger(request),
    })
    response.status(status).json(data)
  }
}

修改apps/api/src/app.module.ts,应用全局异常过滤器

ts
// ...
import { APP_FILTER } from '@nestjs/core'
import { UnifyExceptionFilter } from '@libs/common'
@Module({
  // ...
  providers: [
    // ...
    {
      provide: APP_FILTER,
      useClass: UnifyExceptionFilter,
    },
  ],
})

管道

sh
pnpm add class-validator class-transformer

修改apps/api/src/main.ts,启用全局管道

ts
import { ValidationPipe } from '@nestjs/common'
async function bootstrap() {
  // ...
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // 白名单模式,建议设置,否则不存在于 dto 对象中的键值也会被使用
      transform: true,
    })
  )
}

Swagger

详细的文档看这里

sh
pnpm add @nestjs/swagger swagger-ui-express

修改apps/api/src/main.ts,配置Swagger

ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
async function bootstrap() {
  // ...
  if (process.env.NODE_ENV !== 'production') {
    const options = new DocumentBuilder()
      .setTitle('NestJS-Starter')
      .setDescription('一个NestJS + TypeScript + PM2 + ESLint + Prettier 的基础项目')
      .setVersion('1.0')
      .build()
    const document = SwaggerModule.createDocument(app, options)
    SwaggerModule.setup('swagger', app, document)
  }
}

启动项目,然后打开 http://localhost:3000/swagger 就能查看接口文档了

JWT

sh
pnpm add ms passport passport-jwt passport-local @nestjs/passport @nestjs/jwt
pnpm add -D @types/ms @types/passport-jwt @types/passport-local

创建Auth模块,并新建jwt策略和local策略文件

sh
nest g res auth -p api --no-spec
nest g class jwt.strategy auth -p api --flat --no-spec
nest g class local.strategy auth -p api --flat --no-spec

修改libs/common/src/utils/index.ts,添加加密用的方法

ts
import crypto from 'node:crypto'
class Utils {
  // ...
  /** 生成加密盐 */
  genSalt() {
    return crypto.randomBytes(16).toString('base64')
  }
  /**
   * 密码加密
   * @param password 原密码
   * @param salt 加密盐
   */
  encryptPassword(password: string, salt: string) {
    if (!password || !salt) {
      throw new Error('password or salt is empty')
    }
    const tempSalt = Buffer.from(salt, 'base64')
    return crypto.createHmac('sha256', tempSalt).update(password).digest('hex')
  }
}

编写local策略

查看local.strategy.ts
ts
import {
  ForbiddenException,
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy } from 'passport-local'
import { AuthService } from './auth.service'

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super()
  }
  /** 校验登录信息, 校验通过后会把返回值挂载到 request.user 上 */
  async validate(username: string, password: string): Promise<any> {
    const res = await this.authService.validateUser({ username, password })
    switch (res.type) {
      case 'NORMAL':
        return res.result
      case 'INCORRECT':
        throw new UnauthorizedException(res.message)
      case 'FORBIDDEN':
        throw new ForbiddenException(res.message)
      default:
        throw new NotFoundException(res.message)
    }
  }
}

登录验证逻辑

由于目前还没有使用数据库,所以先用假数据模拟

查看auth.service.ts
ts
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { JwtService } from '@nestjs/jwt'
import { InjectRedis } from '@liaoliaots/nestjs-redis'
import Redis from 'ioredis'
import ms from 'ms'
import { Utils } from '@libs/common'

interface ValidResult {
  type: 'NO_EXIST' | 'FORBIDDEN' | 'INCORRECT' | 'NORMAL'
  message: string
  result: any
}
enum UserStatus {
  NORMAL = 0, // 正常
  LOCKED = 1, // 锁定
  BANNED = 2, // 封禁
}

@Injectable()
export class AuthService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
    private readonly config: ConfigService,
    private readonly jwtService: JwtService
  ) {}
  /**
   * 校验用户信息
   * @param data
   */
  async validateUser(data: { username: string; password: string }): Promise<ValidResult> {
    // TODO 查询数据库校验登录用户信息
    const faker = {
      id: 1,
      username: 'jandan',
      role: 0,
      avatar: '',
      password: 'ad1b1d9d48755cae4cfc406a888fb097cbf18346abdc85569b971a96b620b528', // 123456
      salt: 'sycLRIsMcYuhh2ijW5gWFg==',
      status: UserStatus.NORMAL,
    }
    if (!faker) {
      return {
        type: 'NO_EXIST',
        message: '用户不存在',
        result: null,
      }
    }
    if (faker.status !== UserStatus.NORMAL) {
      return {
        type: 'FORBIDDEN',
        message: '用户已被锁定',
        result: null,
      }
    }
    const isCorrect = faker.password === Utils.encryptPassword(data.password, faker.salt)
    if (!isCorrect) {
      return {
        type: 'INCORRECT',
        message: '账号或密码错误',
        result: null,
      }
    }
    const { password, salt, ...result } = faker
    return {
      type: 'NORMAL',
      message: 'ok',
      result,
    }
  }
  /**
   * 签发JWT
   * @param user
   */
  async certificate(user: any) {
    const token = this.jwtService.sign(user)
    const expires = parseInt(ms(this.config.get('JWT_EXPIRES_IN')))
    await this.redis.setex(`uid:${user.id}`, expires / 1000, token)
    return token
  }
}

登录接口

查看auth.controller.ts
ts
import { Controller, Post, UseGuards, Request } from '@nestjs/common'
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'
import { AuthGuard } from '@nestjs/passport'
import { PublicApi, UserRequest } from '@libs/common'
import { AuthService } from './auth.service'
import { LoginDTO } from './dto/auth.dto'

@ApiTags('Auth模块')
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}
  /**
   * 登录接口
   */
  @ApiOperation({ summary: '登录接口' })
  @ApiBody({ type: LoginDTO })
  @PublicApi()
  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() request: UserRequest) {
    return { token: await this.authService.certificate(request.user) }
  }
}

接口入参校验

新建apps/api/src/auth/dto/auth.dto.ts

ts
import { ApiProperty } from '@nestjs/swagger'
import { IsString, IsNotEmpty } from 'class-validator'

export class LoginDTO {
  @ApiProperty({ required: true, type: String, description: '用户名' })
  @IsNotEmpty({ message: '用户名不能为空' })
  @IsString({ message: '用户名只能是 String 类型' })
  readonly username: string

  @ApiProperty({ required: true, type: String, description: '密码' })
  @IsNotEmpty({ message: '密码不能为空' })
  @IsString({ message: '密码只能是 String 类型' })
  readonly password: string
}

由于local策略属于守卫,它的执行时机早于装饰器(管道)校验,对于这种情况需要把入参校验放到模块中间件去处理

sh
nest g mi login middlewares -p api --flat --no-spec
ts
import { BadRequestException, Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { validate } from 'class-validator'
import { LoginDTO } from '../auth/dto/auth.dto'

@Injectable()
export class LoginMiddleware implements NestMiddleware {
  async use(req: Request, res: Response, next: NextFunction) {
    const body = req.body
    if (Object.keys(body).length !== 0) {
      const loginDto = new LoginDTO()
      Object.keys(body).forEach((key) => {
        loginDto[key] = body[key]
      })
      const errors = await validate(loginDto)
      if (errors.length > 0) {
        const msg = Object.values(errors[0].constraints)[0] // 只取第一个错误信息即可
        throw new BadRequestException(msg)
      }
    }
    next()
  }
}

编写jwt策略

查看jwt.strategy.ts
ts
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // 解析header头中的Bearer token
      ignoreExpiration: false,
      secretOrKey: config.get('JWT_SECRET'),
    })
  }

  /** 校验JWT, 校验通过后会把解析出来的 payload 挂载到 request.user 上 */
  async validate(payload: any) {
    const { iat, exp, ...rest } = payload
    return rest
  }
}

注册到AuthModule

查看auth.module.ts
ts
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { LocalStrategy } from './local.strategy'
import { JwtStrategy } from './jwt.strategy'
import { LoginMiddleware } from '../middlewares/login.middleware'

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        return {
          secret: config.get('JWT_SECRET'),
          signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') },
        }
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoginMiddleware).forRoutes({ path: 'auth/login', method: RequestMethod.POST })
  }
}

更新全局守卫

查看jwt-auth.guard.ts
ts
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { InjectRedis } from '@liaoliaots/nestjs-redis'
import Redis from 'ioredis'
import { AuthGuard } from '@nestjs/passport'
import { IS_PUBLIC_API, UserRequest } from '@libs/common'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    @InjectRedis() private readonly redis: Redis,
    private readonly reflector: Reflector
  ) {
    super()
  }
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 如果是公共开放接口,则直接放行
    if (this.reflector.get(IS_PUBLIC_API, context.getHandler())) return true
    await super.canActivate(context) // 执行父类中的JWT校验
    try {
      let token = context.switchToRpc().getData().headers.authorization
      token = token.split(' ')[1]
      const { user } = context.switchToHttp().getRequest<UserRequest>()
      // 去获取缓存里的 token
      const cacheToken = await this.redis.get(`uid:${user.id}`)
      if (!cacheToken) {
        throw new UnauthorizedException('非法请求,请先登录!')
      } else if (cacheToken !== token) {
        throw new UnauthorizedException('您的账号已在其他地方登录,请重新登录!')
      }
      return true
    } catch (error) {
      throw new UnauthorizedException(error.message || '用户信息解析失败,请重新登录!')
    }
  }
}

🎉

基础的框架封装到这里就结束了

微服务示例

sh
# 安装微服务所需的依赖
pnpm add -F server @nestjs/microservices @grpc/grpc-js @grpc/proto-loader
# 创建应用
nest g app grpc-auth --no-spec
# 删除默认模块
rm -rf apps/grpc-auth/src/grpc-auth.*
# 创建app模块
nest g module app -p grpc-auth --no-spec
# 创建auth模块
nest g module auth -p grpc-auth --no-spec

在项目根目录中新建proto/auth.proto文件,用来定义微服务的功能

proto
syntax = "proto3";

package auth;

service AuthService {
  rpc createToken (payload) returns (resultData) {}
}

message payload {
  string userId = 1;
}

message resultData {
  string token = 1;
}

编辑grpc-auth应用auth模块的auth.controller.ts,填入用于测试gRPC微服务的示例代码

ts
import { Controller } from '@nestjs/common'
import { AuthService } from './auth.service'
import { GrpcMethod } from '@nestjs/microservices'

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

  @GrpcMethod('AuthService', 'createToken')
  public async createToken(data: { userId: string }) {
    return { token: Math.random().toString(36) }
  }
}

编辑grpc-auth应用的入口文件main.ts,改为微服务模式

ts
import path from 'path'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app/app.module'
import { MicroserviceOptions, Transport } from '@nestjs/microservices'
import { ConfigService } from '@nestjs/config'

async function bootstrap() {
  const config = new ConfigService()
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: `${config.get('GRPC_AUTH_HOST')}:${config.get('GRPC_AUTH_PORT')}`,
      package: 'auth',
      protoPath: path.resolve(process.cwd(), 'proto/auth.proto'),
    },
  })
  await app.listen()
}
bootstrap()

编辑网关api应用的app.module.ts,订阅微服务

ts
import path from 'path'
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ClientProxyFactory, Transport } from '@nestjs/microservices'

@Module({
  // ...
  providers: [
    {
      provide: 'GRPC_AUTH_SERVICE',
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        return ClientProxyFactory.create({
          transport: Transport.GRPC,
          options: {
            url: `${config.get('GRPC_AUTH_HOST')}:${config.get('GRPC_AUTH_PORT')}`,
            package: 'auth',
            protoPath: path.resolve(process.cwd(), 'proto/auth.proto'),
          },
        })
      },
    },
  ],
})
export class AppModule {}

编辑网关api应用的app.controller.ts,调用测试用的微服务功能

ts
import { Controller, Get, OnModuleInit, Inject, Query } from '@nestjs/common'
import { ClientGrpc } from '@nestjs/microservices'

interface AuthService {
  createToken(data: { userId: string }): Promise<any>
}

@Controller()
export class AppController implements OnModuleInit {
  private authService: AuthService
  constructor(@Inject('GRPC_AUTH_SERVICE') private readonly client: ClientGrpc) {}

  onModuleInit() {
    this.authService = this.client.getService<AuthService>('AuthService')
  }

  @Get('/auth')
  public async createToken(@Query() query) {
    const token = await this.authService.createToken({ userId: query.id })
    return token
  }
}

使用任意接口测试工具请求http://localhost:3000/auth

MIT License