nestjs 起步

实现什么?

  1. 一个 RESTful API 程序,并对 传输的数据(DTO) 进行校验(是否必要、类型
  2. 支持跨域
  3. 支持定时任务
  4. 数据库(MySQL、TypeORM
  5. 基于 JWT 做接口鉴权
  6. 使用 Swagger 自动生成接口文档
  7. 基于 socket.io 支持实时通信。实现聊天室前端页面并作为静态文件挂载

一些个人化的想法

  1. 移除 service 中间层,会在 controller 中直接访问数据库
  2. 不组合多个 module ,只留一个全局 module。所有的 controllerservice 等都直接添加到全局 module。只在代码层面组织不同的模块
  3. 日志交给系统管理,比如 journalctl 。不考虑将日志写到文件的需求,只使用框架自身提供的日志能力
  4. 暂时只使用框架提供的异常类。比如:鉴权失败、资源未找到

零、初始化项目

步骤:

  1. 创建起始项目
  2. 结构简化、调整配置
  3. 区分正式和开发环境

创建项目:

npm i -g @nestjs/cli
nest new project-name

运行 start 脚本,打开浏览器地址访问 http://localhost:3000 (端口号在 src/main.ts 中设置)

环境区分:

使用 cross-env 在 npm 脚本执行前定义 NODE_ENV 环境变量

ni -D cross-env

package.json

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=dev nest start --watch",
    "start:prod": "cross-env NODE_ENV=prod node dist/main",
  },
  // ...
}

一、RESTful API 与 数据校验

步骤:

  1. 创建 用户的查询、新增、删除接口。实现从 请求体、路由 上取参数
  2. 数据校验转换
  3. 根据情况返回不同的状态码和信息
  4. 使用内置异常自定义异常处理逻辑

创建组件类后,需要注册到 app.module 才能生效。也可以使用 nest cli 创建组件并自动注册到 app.module,参考官方文档。比如我创建 controller 的方式:

# 安装类验证器和转换器
ni class-validator class-transformer

# 在 modules/${path} 路径下生成一个 ${name}.controller.ts
# --flat 指示只创建文件,不加会多生成一个文件夹
# --no-spec 指示不创建测试用例
nest g controller ${name} modules/${path} --flat --no-spec

# 例子
# nest g controller login modules/user --flat --no-spec
# nest g controller user modules/user --flat --no-spec

user.dto.ts

// 数据校验,这里用到的不能为空
import { IsNotEmpty } from 'class-validator';

/**
 * 登录接口 DTO
 */
export class LoginDto {
  @IsNotEmpty()
  name: string;

  @IsNotEmpty()
  password: string;
}

user.controller.ts

@Controller('user')
export class UserController {
  @Get()
  async all() {}

  // 会对 dto 做数据校验
  @Post()
  async add(@Body() dto: LoginDto) {}

  // id 转换成 int
  @Delete(':id')
  async remove(@Param('id', ParseIntPipe) id: number) {}
}

全局启用校验:

app.useGlobalPipes(new ValidationPipe());

自定义异常校验逻辑(暂时不做):

  1. 写一个filter
  2. src/main.ts 中引入 app.useGlobalFilters(new YourExceptionFilter());

二、跨域配置

步骤:

  1. 配置跨域
  2. 写测试(通过设置Origin然后检查Access-Control-Allow-Origin

main.ts

async function bootstrap() {
  // ...

  // 配置参数 https://github.com/expressjs/cors#configuration-options
  app.enableCors({
    origin: ['http://localhost:3002'],
  });

  // ...
}
bootstrap();

app.e2e-spec.ts

import * as request from 'supertest';
const SERVER_LOCATION = `http://localhost:3000`;

// 直接在服务器启动的情况下测试

describe('AppController (e2e)', () => {
  const origin = 'http://localhost:3002';
  it('跨域测试', () => {
    return request(SERVER_LOCATION)
      .options('/')
      .set('Origin', origin)
      .expect('Access-Control-Allow-Origin', origin);
  });
});

三、定时任务

步骤:

  1. 安装
  2. 写定时任务
  3. 在模块中注册(注意需要使用imports: [ScheduleModule.forRoot()]

安装:

ni @nestjs/schedule

定时任务有三种方便的 Api:

  1. @Cron,除了自己写 cron 表达式,也可以直接使用系统定义好的 CronExpression 枚举
  2. @Interval,定时执行。传入 ms 为单位的数值
  3. @TimeOut,启动后延时执行一次。传入 ms 为单位的数值

task.schedule.ts

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule';

/**
 * @see [定时任务](https://docs.nestjs.cn/8/techniques?id=%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1)
 */
@Injectable()
export class TasksSchedule {
  private readonly logger = new Logger(TasksSchedule.name);

  @Cron(CronExpression.EVERY_DAY_AT_6PM)
  task1() {
    this.logger.debug('task1 - 每天下午6点执行一次');
  }

  @Cron(CronExpression.EVERY_2_HOURS)
  task2() {
    this.logger.debug('task2 - 每2小时执行一次');
  }

  @Interval(30 * 60 * 1000)
  task3() {
    this.logger.debug('task3 - 每30分钟执行一次');
  }

  @Timeout(5 * 1000)
  task4() {
    this.logger.debug('task4 - 启动5s后执行一次');
  }
}

四、使用 TypeORM 连接 MySQL 数据库

官方文档

重点:

  1. TypeOrmModule.forRoot() 用来配置数据库,导入数据库模块
  2. TypeOrmModule.forFeature() 用来定义在当前范围中需要注册的数据库表
ni @nestjs/typeorm typeorm mysql2
@Module({
  imports: [
    // 导入模块
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'test',
      autoLoadEntities: true, // 自动加载 forFeature 使用到的 entity
      synchronize: true, // 自动同步数据库和字段,会在数据库中创建没有的字段
    }),
    // 定义在当前范围中需要注册的存储库
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [UserController, LoginController],
})
export class AppModule {}

使用(详细 api 参考 Repository 模式 API 文档):

user.controller.ts

@Controller('user')
export class UserController {
  constructor(
    // 依赖注入
    // 注入后就可以使用 find()、save($user) 等方法
    @InjectRepository(User)
    private repository: Repository<User>
  ) {}
}

事务使用:

async createMany(users: User[]) {
  await this.connection.transaction(async manager => {
    await manager.save(users[0]);
    await manager.save(users[1]);
  });
}

五、基于 JWT 做接口鉴权

官方文档

步骤:

  1. 安装 ni @nestjs/passport @nestjs/jwt passport passport-jwtni -D @types/passport-jwt
  2. 写认证模块(auth.service.ts)并全局引入
  3. 使用@NoAuth配置无需认证的路由

auth.service.ts

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  SetMetadata,
  UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { MD5 } from 'src/app/utils';
import { Repository } from 'typeorm';
import { User } from '../modules/user/user.entity';
import { AuthGuard, IAuthGuard, PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { AppConfig } from '../app/app.config';
import { ExtractJwt, Strategy } from 'passport-jwt';

/**
 * 无需认证的路由
 */
export const NoAuth = () => SetMetadata('no-auth', true);

/**
 * 认证服务。校验登录信息、生成 token
 */
@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User) private readonly userRepo: Repository<User>,
    private readonly jwtService: JwtService
  ) {}

  /**
   * 校验用户登录信息
   */
  async validate(name: string, password: string): Promise<any> {
    const user = await this.userRepo.findOneBy({ name });

    if (!user || user.password !== (await MD5.encode(password))) {
      return null;
    }
    return user.removeSensitive();
  }

  /**
   * 生成 token
   */
  async generateToken(user: User) {
    const payload = user;
    return {
      access_token: this.jwtService.sign(payload),
    };
  }

  parseToken(token: string): User {
    return this.jwtService.decode(token) as User;
  }
}

/**
 * 认证守卫
 * @description 如果未设置 `@NoAuth()`,则使用 JwtStrategy 进行校验。配合 app.module 做全局校验用
 */
@Injectable()
export class MyAuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 在这里取metadata中的no-auth,得到的会是一个bool
    const noAuth = this.reflector.get<boolean>('no-auth', context.getHandler());
    const guard = MyAuthGuard.getAuthGuard(noAuth);
    if (guard) {
      return guard.canActivate(context);
    }
    return true;
  }

  // 根据NoAuth的t/f选择合适的策略Guard
  private static getAuthGuard(noAuth: boolean): IAuthGuard {
    if (noAuth) {
      return null;
    } else {
      return new JwtAuthGuard();
    }
  }
}

/**
 * Jwt 校验策略
 */
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: AppConfig.JWT_SECRET,
    });
  }

  async validate(payload: any) {
    return payload;
  }
}

/**
 * Jwt 校验守卫
 * @description 主要为了自定义异常逻辑
 */
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err, user) {
    if (err || !user) {
      throw new UnauthorizedException('请登录后再访问');
    }
    return user;
  }
}

全局启用:

app.module.ts

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: AppConfig.JWT_SECRET,
      // https://github.com/auth0/node-jsonwebtoken#usage
      signOptions: { expiresIn: AppConfig.JWT_EXPIRES_IN },
    }),
  ],
  controllers: [UserController, LoginController],
  providers: [
    AuthService,
    JwtStrategy,
    {
      // 全局启用
      provide: APP_GUARD,
      useClass: MyAuthGuard,
    },
  ],
})
export class AppModule {}

为部分无需认证的路由设置@NoAuth

@Controller('login')
export class LoginController {

+ @NoAuth()
  @Post()
  async login(@Body() { name, password }: LoginDto) {}
}

六、使用 Swagger 自动生成接口文档

参考文档

步骤:

  1. 安装:ni @nestjs/swagger swagger-ui-express
  2. main.ts中配置使用
  3. nest-cli.json配置插件,以自动映射属性注释
  4. 使用@ApiTags为接口分组,@ApiOperation为接口增加描述,@ApiBearerAuth为接口添加认证

main.ts

// https://docs.nestjs.cn/8/openapi
const config = new DocumentBuilder()
  .setTitle('NestJS API')
  .setDescription('API 文档')
  .setVersion('1.0')
  .addBearerAuth() // JWT 认证
  .build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swagger', app, document); // 挂载到 /swagger 路由下

注释:

  1. @ApiTags('用户管理') 分组
  2. @ApiOperation({ summary: '获取用户信息' }) 用户函数
  3. @ApiBearerAuth() 需要 jwt 认证,用于函数
  4. 属性的注释可以通过插件配置自动生成

也可以使用装饰器聚合来组合使用多个装饰器:

app.decorator.ts

import { applyDecorators, Controller } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';

/**
 * 复合装饰器
 */
export function ApiController(route: string, name: string = route) {
  return applyDecorators(
    ApiBearerAuth(), //
    ApiTags(name),
    Controller(route)
  );
}

user.controller.ts

+@ApiController('user', '用户管理')
-@Controller('user')
-@ApiBearerAuth()
-@ApiTags('用户管理')
export class UserController {

+ @ApiOperation({ summary: '获取用户信息' })
  @Get()
  async all() {

  }
}

七、基于 socket.io 实现聊天室,并挂载前端页面

中文文档

  1. 安装依赖:ni ni @nestjs/websockets @nestjs/platform-socket.io socket.io
  2. 实现后端功能 chat.gateway.ts,并注册到app.module.ts中的providers
  3. 实现前端页面,放在static/socket路径下,并在main.ts中配置静态文件访问

chat.gateway.ts

import {
  SubscribeMessage,
  WebSocketGateway,
  OnGatewayInit,
  WebSocketServer,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger, UseGuards } from '@nestjs/common';
import { Socket, Server } from 'socket.io';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';

enum SocketEvent {
  System = 'system',
  Message = 'message',
  Statistic = 'statistic',
}

@WebSocketGateway({
  namespace: '/chat',
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private clients: Map<string, Socket> = new Map();

  constructor(private readonly authService: AuthService) {}

  @WebSocketServer() server: Server;
  private logger: Logger = new Logger(ChatGateway.name);

  @SubscribeMessage(SocketEvent.Message)
  handleMessage(client: Socket, payload: string): void {
    this.server.emit(SocketEvent.Message, payload);
  }

  afterInit(_: Server) {
    this.logger.log('聊天室初始化');
  }

  handleDisconnect(client: Socket) {
    this.logger.log(`WS 客户端断开连接: ${client.id}`);
    this.clients.delete(client.id);
    this.sendStatistics();
  }

  @UseGuards(AuthGuard('jwt'))
  handleConnection(client: Socket) {
    // TOKEN 校验

    // const token = client.handshake.headers.authorization;
    // const user = this.authService.parseToken(token.split(' ')[1]);
    // if (!user) {
    //   return client.disconnect(true);
    // }

    this.logger.log(`WS 客户端连接成功: ${client.id}`);
    this.clients.set(client.id, client);
    client.emit(SocketEvent.System, '聊天室连接成功');
    this.sendStatistics();
  }

  sendStatistics() {
    this.server.emit(SocketEvent.Statistic, this.clients.size);
  }
}

main.ts

+ import { NestExpressApplication } from '@nestjs/platform-express';
+ import { join } from 'path';

async function bootstrap() {
- const app = await NestFactory.create(AppModule);
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // ...

  // 提供静态文件访问
+ app.useStaticAssets(join(__dirname, '..', 'static'));

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

前端代码见提交记录

made in earth, without love_