nest-todo
简单介绍
刚学习完nest,为了理解一些nest的东西是怎么进行配置的,因此写了一个这样的一个简单的demo
基本就是简陋的前端页面配合功能较多的nest实现的后端
实现流程:
- user和todo 两个资源的CURD接口(大部分的教程也就是只有这个)
- 数据库模块,TypeOrm + MySQL实现的,数据库迁移,数据库seed
- 文件上传模块,我这里是直接上传到了本地,(可以学一下使用Node SDK上传到OSS当中)
- 日志模块,ReportLogger 模拟日志上报
- 静态资源模块,使用StatiecModule实现
- 用户的身份认证,local和JWT两种认证策略
- 用户角色验证,区分普通的用户和管理员两种角色
- Docker部署环境
- Swagger 构建API文档
- WebSocket 实现数据传输
- Http模块,http的转发功能
- Error模块,出错时,拦截错误,并将当错误按照一定的格式返回
- Transform模块,使用一定格式进行返回数据
- Task Scheduling 定时推送消息
- 编写单元测试
- 编写e2e测试
技术栈:
前端:
- vue + typescript
后端:
- NestJS
- Typescript
- TypeORM
- MySQL
- Swagger
一些模块的实现思路
实现路由Guard思路
就像我们知道的express实现的中间件之类的(eg:根据login登录的信息拿到对应身份)
在express当中我们通过实现中间件来实现
import { userDAO } from "../dao/user.js";
import { response } from "../utils/response.js";
export const permissionHandler = (roleId = 10) => {
return async (req, res, next) => {
const { id } = req.auth;
if (!id) {
return res.json(response.fail("missing id"));
}
const { status, message, result } = await userDAO.findOne(id);
if (!status) res.json(response.fail(message));
const [resultFirst] = result;
if (resultFirst && resultFirst.role_id === roleId) {
next();
} else {
res.json(response.accessDenied());
}
};
};
在nest当中,我们反而不推荐再次使用这种中间件的方式来进行路由的鉴权
这里我们推荐使用 Guard + Decorator的方式来进行路由的鉴权 类似下面的这样
@UseGuards(AuthGuard, PermissionGuard)
@Permission(10)
@Get('getAllTask')
findAll() {
return this.todoService.findAll();
}
定义@Permission装饰器来获取指定当前路径中什么的具体角色访问权限 给定权限的具体值
import { SetMetadata } from '@nestjs/common';
// 创建特定的装饰器 SetMetadata是专门的装饰器 用来字方法/类上面存储元数据
// 后面就可以使用 @Permission 进行操作 声明式权限控制
export const Permission = (roleId: number = 10) =>
SetMetadata('permission:roleId', roleId);
后面我们定义authGuard从request请求当中拿到访问的header,body等信息,我们将其绑定到request上面(显式)
便于后面对路由实现鉴权操作
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
@Injectable()
export class PermissionGuard implements CanActivate {
// 进行依赖注入
constructor(private userService: UserService) {}
canActivate( // Guard的核心接口 nest会在路由的前面进行调用
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// @Permission 中拿到的
const roleId = this.getRoleId(context) || 10;
const request = context.switchToHttp().getRequest();
const userId = request.user?.sub; // 从authGuard当中设置的
if (!userId) {
throw new ForbiddenException('missing user ID');
}
return this.validateRole(userId, roleId);
}
// 封装单独的函数 进行业务逻辑的操作
private async validateRole(userId: number, expectedRoleId: number): Promise<boolean> {
const user = await this.userService.findOne(userId)
if(!user || user.roleId !== expectedRoleId){
throw new ForbiddenException('Access denied')
}
return true
}
private getRoleId(context: ExecutionContext): number|undefined {
return Reflect.getMetadata('permission:roleId', context.getHandler())
}
}
设置成功之后记得在前端请求的时候携带请求头
实现文件上传功能
安装对应的插件
npm install --save @nestjs/platform-express multer
npm install --save-dev @types/multer
创建上传模块
import {
Controller,
HttpException,
HttpStatus,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
/**
* 实现NestJS当中的文件上传功能,专门用来处理图片上传功能
* 基于express的multer中间件
*/
@Controller('upload')
export class UploadController {
@Post('image')
// 文件上传拦截器,处理文件解析和验证
@UseInterceptors(
FileInterceptor('file', {
// dickStorage存储配置
storage: diskStorage({
destination: './uploads', // 保存到 uploads 文件夹
filename: (req, file, callback) => {
// 生成唯一文件名:时间戳 + 随机数 + 扩展名
const uniqueSuffix =
Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname).toLowerCase();
callback(null, `image-${uniqueSuffix}${ext}`);
},
}),
// 文件过滤
fileFilter: (req, file, callback) => {
// 只允许图片格式
if (!file.mimetype.match(//(jpg|jpeg|png|gif)$/)) {
return callback(
new HttpException(
'只支持 JPG、JPEG、PNG、GIF 格式',
HttpStatus.BAD_REQUEST,
),
false,
);
}
callback(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024, // 限制 5MB
},
}),
)
// UploadFile获取文件上传
uploadImage(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new HttpException('文件上传失败', HttpStatus.BAD_REQUEST);
}
// 返回可访问的 URL(假设静态资源通过 /uploads 提供)
return {
url: `/uploads/${file.filename}`,
originalName: file.originalname,
size: file.size,
};
}
}
在main当中设置支持 访问静态资源的服务 直接通过路径进行静态资源的访问
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads/',
}),
实现日志上报功能
做一个类似于记事本的类(ReportLogger)
// report.logger.ts
import { Logger } from '@nestjs/common';
export class ReportLogger extends Logger {
// 重写 log 方法:除了自己记,还要上报
log(message: string) {
// 1. 自己先记一下(控制台打印)
super.log(message);
// 2. 模拟上报给总部
console.log(`[📤 上报总部] [部门: ${this.context}] 说: ${message}`);
}
}
this.context是NextJS基类自带的属性,用来存储部门名字
但是context怎么进行绑定(不能所有的部门使用同一个笔记本吧)
如果ReportLogger进行全局的注册,那么只会生成一个固定的笔记本,这不是我们想要的
解决办法:创建一个记事本工厂,我们告诉部门的名字,自动做好具有名字的新的记事本,在NestJS当中,就需要我们使用动态模块来实现(Dynamic Module)
创建记事本工厂(ReoirtModule)
// report-logger.module.ts
import { Module, DynamicModule, Provider, Scope } from '@nestjs/common';
import { ReportLogger } from './report.logger';
@Module({})
export class ReportLoggerModule {
// 静态方法:你告诉我部门名,我返回一个“定制模块”
static forFeature(departmentName: string): DynamicModule {
return {
module: ReportLoggerModule,
providers: [
{
provide: ReportLogger, // 注册一个“记事本”
useFactory: () => {
// 工厂函数:现场做一个新记事本,印上部门名!
return new ReportLogger(departmentName);
},
scope: Scope.TRANSIENT, // 重要!每次都要新做一本,不能共用
},
],
exports: [ReportLogger], // 允许其他模块使用这个记事本
};
}
}
在用户部门进行使用
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { ReportLoggerModule } from '../report-logger/report-logger.module';
@Module({
imports: [
// 告诉工厂:我们要一个部门名叫 "UsersService" 的记事本!
ReportLoggerModule.forFeature('UsersService')
],
providers: [UsersService],
})
export class UsersModule {}
// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { ReportLogger } from '../report-logger/report.logger';
@Injectable()
export class UsersService {
// NestJS 会自动给我们一个“印着 UsersService 的记事本”
constructor(private readonly logger: ReportLogger) {}
handleLogin() {
this.logger.log('用户登录成功');
// 输出:
// [Nest] ... - [UsersService] 用户登录成功
// [📤 上报总部] [部门: UsersService] 说: 用户登录成功
}
}
swagger文档生成
安装插件
@nestjs/swagger
在main当中进行定义 定义swagger文档模型
const config = new DocumentBuilder()
.setTitle('我的api')
.setDescription('NestJS 项目API文档')
.setVersion('1.0')
.addTag('用户')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document)
在DTO当中对数据模型展示设置例子
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class CreateTodoDto {
@ApiProperty({ example: 'title', description: '标题' })
@IsString()
title: string;
@ApiProperty({ example: '吃饭', description: '内容' })
@IsString()
content: string;
}
在controller层面对swagger建立的文档进行注释操作
@Post('addTask')
@ApiOperation({ summary: '添加任务' })
@ApiResponse({ status: 201, description: '任务添加成功' })
create(@Body() createTodoDto: CreateTodoDto) {
this.logger.log('添加新的任务');
return this.todoService.create(createTodoDto);
}
wesocket 模块
安装插件
@nestjs/websockets socket.io
创建gateway网关模块
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { Server } from 'http';
@WebSocketGateway({ // 标记网关接口
namespace: 'chat', // 所有的连接必须携带/chat前缀 隔离网关
cors: {
origin: '*', // 允许任何域名连接
},
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer() server: Server; // 注入底层的socketIO服务器实例
// 底层的服务器对象进行注入 赋值给this.server
private logger: Logger = new Logger('ChatGateway');
users = 0; // 统计在线人数
@SubscribeMessage('msgToServer') // 监听传递的消息 前端调用 socket.emit('msgToServer', 'hello')调用这个方法
handleMessage(client: Socket, payload: string): void {
// 根据当前时间格式化
const currentTime = new Date()
.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(///g, '-')
.replace(/,/, ' '); // 替换分隔符号,符合所需要的格式
const messageWithTime = {
time: currentTime,
data: payload,
};
this.server.emit('msgToClient', messageWithTime); // 发送到这个网关的客户端
}
afterInit(server: Server) {
this.logger.log('init');
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnect: ${client.id}`);
this.users--;
this.server.emit('users', this.users);
}
handleConnection(client: Socket, ...args: any[]) {
this.logger.log(`Client connect: ${client.id}`);
this.users++;
this.server.emit('users', this.users);
}
}
在chatModule中使用
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat/chat.gateway';
@Module({
providers: [ChatGateway]
})
export class ChatModule {}
// 记得在appMoudle中进行注册
Http模块
方法一:使用http-proxy-middleware直接实现转功能
直接在main当中进行定义即可
app.use('/api/test', createProxyMiddleware({
target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: {
'^/api/test': '/public'
}
}))
方法二:使用HttpService进行手动转发
// 在appModule中进行注册
import // 进行注册
// 创建代理控制器 实现路径请求的转发
// proxy.controller.ts
import { Controller, Get, Query, Post, Body, Headers } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
@Controller('api')
export class ProxyController {
constructor(private readonly httpService: HttpService) {}
// 转发 GET 请求
@Get('users')
async getUsers(@Query() query, @Headers() headers) {
const response = await firstValueFrom(
this.httpService.get('http://user-service:4001/users', {
params: query,
headers: { ...headers, host: undefined }, // 避免 Host 冲突
}),
);
return response.data; // 返回目标服务的响应
}
// 转发 POST 请求
@Post('users')
async createUser(@Body() body, @Headers() headers) {
const response = await firstValueFrom(
this.httpService.post('http://user-service:4001/users', body, {
headers,
}),
);
return response.data;
}
}
error错误模块
建立健壮的代码,实现全局的error模块,捕获全局的错误
实现思路:
- 自定义异常类
- 全局异常过滤器
- 错误日志记录
- 标准化异常处理
- 项目中使用
创建自定义异常类
// 举例说明 自定义的business类
import { HttpException, HttpStatus } from "@nestjs/common";
// 自定义异常类
export class BusinessException extends HttpException {
constructor(message: string | object, errorCode: string = 'BUSINESS_ERROR') {
super(
{
statusCode: HttpStatus.BAD_GATEWAY,
errorCode,
message: message,
timestamp: new Date().toISOString()
},
HttpStatus.BAD_GATEWAY,
)
}
}
创建全局异常过滤器(核心)
进行错误的判断和异常的捕获,这样不需要在使用trycatch进行错误的捕获
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
//创建一个Logger专门记录错误的日志实例
const errorLogger = new Logger('GlobalExceptionFilter');
// 创建一个Logger错误捕获器
@Catch() // 异常过滤器
export class GlobalExceptionFilter implements ExceptionFilter {
// 抛出异常对象,数据库错误,自定义错误等
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 默认错误结构
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let errorResponse = {
statusCode: status,
errorCode: 'INTERNAL_ERROR',
message: 'Internal server error',
timestamp: new Date().toISOString(),
path: request.url,
};
// 如果是HttpException的错误形式(包括自定义的错误)
if (exception instanceof HttpException) {
const httpException = exception;
// 获取错误携带的响应内容
const responseBody = httpException.getResponse() as any;
// 当错误的内容是对象,就保持原有的错误结构
if (typeof responseBody === 'object' && responseBody !== null) {
errorResponse = {
...responseBody,
timestamp: responseBody.timestamp || new Date().toISOString(),
path: request.url,
};
status = httpException.getStatus();
} else {
// 当异常响应是字符串
errorResponse = {
statusCode: httpException.getStatus(),
errorCode: 'HTTP_EXCEPTION',
message: responseBody,
timestamp: new Date().toISOString(),
path: request.url,
};
status = httpException.getStatus();
}
} else {
// 不是HttpExcetion 就像数据库错误,未捕获异常之类的
errorLogger.error(
`Unexception error: ${exception}`,
(exception as any).stack,
'GlobalExceptionFilter',
);
}
if (status >= 500) {
errorLogger.error(`5xx Error: ${JSON.stringify(errorResponse)}`);
} else {
errorLogger.warn(`4xx Error: ${JSON.stringify(errorResponse)}`);
}
(response as any).status(status).json(errorResponse)
}
}
在main中启用全局的过滤器
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './errors/global-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 启用全局异常过滤器
app.useGlobalFilters(new GlobalExceptionFilter());
await app.listen(3000);
}
bootstrap();
后面我们就可以直接在service层throw抛出错误(举例)不需要调用trycatch就可以进行操作
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { BusinessException } from '../errors/business.exception';
@Injectable()
export class UsersService {
private users = [{ id: 1, name: 'Alice' }];
findOne(id: number) {
const user = this.users.find(u => u.id === id);
if (!user) {
// 方式1:使用 NestJS 内置异常
throw new NotFoundException(`User with ID ${id} not found`);
// 方式2:使用自定义异常
// throw new BusinessException(`User with ID ${id} not found`, 'USER_NOT_FOUND');
}
return user;
}
create(name: string) {
if (!name || name.length < 2) {
throw new BusinessException('Name must be at least 2 characters', 'INVALID_NAME');
}
return { id: this.users.length + 1, name };
}
}
实现transform模块规定格式返回数据
import { Exclude, Expose, Transform, Type } from 'class-transformer';
// 表示默认排除所有的片段 只暴露标记的
@Exclude()
export class UserResponseDto {
// 直接定义暴露的接口
@Expose()
id: number;
@Expose({ name: 'user_email' })
email: string;
// 定义需要经过转换的接口
@Expose()
@Transform((value) => {
if (value instanceof Date) {
return value.toISOString().split('T')[0];
}
return value;
})
createdAt: Date;
// 对于某些敏感的模块直接取消Expose (password之类的)
// 添加计算字段 返回计算之后的属性
// @Expose()
// get fullName(): string {
// return `${this.firstName}${this.lastName}`;
// }
// 嵌套对象
@Expose()
@Type(() => ProfileDto)
profile: ProfileDto;
}
@Exclude()
class ProfileDto {
@Expose()
bio: string;
@Expose()
avatarUrl: string;
}
之后就可以在controller中启动自动转换
// users.controller.ts
import {
Controller,
Get,
UseInterceptors,
,
} from '@nestjs/common';
import { UserEntity } from './user.entity'; // 假设这是你的数据库实体
@Controller('users')
export class UsersController {
@Get()
@UseInterceptors(ClassSerializerInterceptor) // ← 关键:启用自动转换
findAll(): UserResponseDto[] {
// 返回原始实体,会被自动转换为 UserResponseDto 格式
const users: UserEntity[] = [
{
id: 1,
email: 'alice@example.com',
password: 'secret123', // 这个字段不会出现在响应中!
firstName: 'Alice',
lastName: 'Smith',
createdAt: new Date(),
profile: {
bio: 'Software engineer',
avatarUrl: 'https://example.com/avatar.jpg',
},
},
];
return users as any; // 类型断言(实际返回实体,但声明为 DTO)
}
}
或者直接在全局当中设置拦截的数据
对于特定的数据格式可以直接进行转换
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClassSerializerInterceptor } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局启用序列化拦截器
app.useGlobalInterceptors(new ClassSerializerInterceptor());
await app.listen(3000);
}
bootstrap();
任务调度模块
任务调度:每五分钟清理内存,每天凌晨自动生成报表...
核心概念:
Corn 表达式:一种字符串格式,定义任务执行时间,格式
* * * * * *
│ │ │ │ │ └ 秒(可选)
│ │ │ │ └── 星期(0-7,0 和 7 都是周日)
│ │ │ └──── 月(1-12)
│ │ └────── 日(1-31)
│ └──────── 时(0-23)
└────────── 分(0-59)
举例
*/5 * * * *
每 5 分钟
0 9 * * *
每天上午 9 点
0 0 * * 0
每周日午夜
0 0 1 * *
每月 1 号午夜
两种任务模型
Corn Job 基于Corn表达式的周期性任务
Timeout/Interval 延迟/固定时间执行
使用方法
安装任务调度插件
npm install --save @nestjs/schedule
AppModule启用插件
// app.module.ts
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [ScheduleModule.forRoot()], // ← 关键:启用调度器
// ...
})
export class AppModule {}
启用必须写在@Injectable中的Service当中,不能直接在Controller/Gateway当中写
举例:基础服务示例
// tasks.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule';
@Injectable()
export class TasksService {
// 每 10 秒执行
@Cron(CronExpression.EVERY_10_SECONDS)
handleCron() {
console.log('每 10 秒执行一次');
}
// 每 5 分钟执行(手写 Cron)
@Cron('*/5 * * * *')
handleEveryFiveMinutes() {
console.log('每 5 分钟执行');
}
// 启动后延迟 3 秒执行一次
@Timeout(3000)
handleTimeout() {
console.log('延迟 3 秒执行');
}
// 每 2 秒执行一次
@Interval(2000)
handleInterval() {
console.log('每 2 秒执行');
}
}
实战:将聊天室中集成任务调度,实现定时发送消息
在chat网关当中定义广播消息函数
boardcastMessage(message: string){
const payload = {
time: new Date().toLocaleString('zh-CN'),
data: `[系统]${message}`
}
this.server.emit('msgToClient', payload)
}
这样我们就可以在服务层定时调用方法 实现定时发送消息
import { Injectable } from '@nestjs/common';
import { ChatGateway } from './chat/chat.gateway';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class ChatSchedulerService {
constructor(private readonly chatGateway: ChatGateway) {}
@Cron(CronExpression.EVERY_5_SECONDS)
sendPeriodicAnnouncement() {
this.chatGateway.boardcastMessage('记得完成代办事项哦!');
}
@Cron('0 * * * *')
sendHourlyTime() {
const hour = new Date().getHours();
this.chatGateway.boardcastMessage(`现在是${hour}点整`);
}
}