实现什么?
- 一个 RESTful API 程序,并对 传输的数据(DTO) 进行校验(是否必要、类型)
- 支持跨域
- 支持定时任务
- 数据库(MySQL、TypeORM)
- 基于 JWT 做接口鉴权
- 使用 Swagger 自动生成接口文档
- 基于 socket.io 支持实时通信。实现聊天室前端页面并作为静态文件挂载
一些个人化的想法
- 移除 service 中间层,会在 controller 中直接访问数据库
- 不组合多个
module
,只留一个全局module
。所有的controller
、service
等都直接添加到全局module
。只在代码层面组织不同的模块 - 日志交给系统管理,比如
journalctl
。不考虑将日志写到文件的需求,只使用框架自身提供的日志能力 - 暂时只使用框架提供的异常类。比如:鉴权失败、资源未找到
步骤:
- 创建起始项目
- 结构简化、调整配置
- 区分正式和开发环境
创建项目:
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",
},
// ...
}
步骤:
创建组件类后,需要注册到 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());
自定义异常校验逻辑(暂时不做):
- 写一个
filter
- 在
src/main.ts
中引入app.useGlobalFilters(new YourExceptionFilter());
步骤:
- 配置跨域
- 写测试(通过设置
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);
});
});
步骤:
- 安装
- 写定时任务
- 在模块中注册(注意需要使用
imports: [ScheduleModule.forRoot()]
)
安装:
ni @nestjs/schedule
定时任务有三种方便的 Api:
@Cron
,除了自己写 cron 表达式,也可以直接使用系统定义好的CronExpression
枚举@Interval
,定时执行。传入ms
为单位的数值@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后执行一次');
}
}
重点:
TypeOrmModule.forRoot()
用来配置数据库,导入数据库模块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]);
});
}
步骤:
- 安装
ni @nestjs/passport @nestjs/jwt passport passport-jwt
、ni -D @types/passport-jwt
- 写认证模块(
auth.service.ts
)并全局引入 - 使用
@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) {}
}
步骤:
- 安装:
ni @nestjs/swagger swagger-ui-express
main.ts
中配置使用nest-cli.json
中配置插件,以自动映射属性注释- 使用
@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 路由下
注释:
@ApiTags('用户管理')
分组@ApiOperation({ summary: '获取用户信息' })
用户函数@ApiBearerAuth()
需要 jwt 认证,用于函数- 属性的注释可以通过插件配置自动生成
也可以使用装饰器聚合来组合使用多个装饰器:
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() {
}
}
- 安装依赖:
ni ni @nestjs/websockets @nestjs/platform-socket.io socket.io
- 实现后端功能
chat.gateway.ts
,并注册到app.module.ts
中的providers
中 - 实现前端页面,放在
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();
前端代码见提交记录