最简单的Nest项目,虽然很丑,但是有用

60 阅读8分钟

nest-todo

简单介绍

刚学习完nest,为了理解一些nest的东西是怎么进行配置的,因此写了一个这样的一个简单的demo

基本就是简陋的前端页面配合功能较多的nest实现的后端

仓库地址 github.com/Huuyii-slee…

实现流程:

  • 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}点整`);
  }
}