NestJs 上手之路之二 【连接Mysql数据库、项目框架调整优化】

3,561 阅读7分钟

前言

上一篇文章是用的mongoDB数据库,由于考虑到很多企业是用的mysql数据库,所以我将数据库改为mysql,并且重新调整框架和CRUD的功能,比如调整目录结构、添加过滤器、拦截器、封装分页、封装返回数据结构、表设计等等我们做项目前统一的框架调整。

目录调整

src目录下面分别新建common(公共代码)和modules(业务相关代码)

src
├─ app.controller.spec.ts
├─ app.controller.ts
├─ app.module.ts
├─ app.service.ts
├─ main.ts
├─ common
│  ├─ common // 公共dto和dto等等
│  │  ├─ dto
│  │  │  ├─ base.dto.ts // 公共的类
│  │  │  ├─ pagination.dto.ts // 分页
│  │  │  └─ result.dto.ts // 结果返回
│  │  └─ entity
│  │     └─ base.entity.ts // 公共的实体
│  ├─ config // 环境配置
│  ├─ exception // 异常封装
│  │  └─ error.code.ts // 异常的code类
│  ├─ filters
│  │  └─ http-execption.filters.ts // 过滤器
│  ├─ interceptor
│  │  └─ transform.interceptor.ts // 拦截器
│  ├─ pipe
│  │  └─ validate.pipe.ts // 类验证
│  └─ utils // 封装的工具
│     ├─ convert.utils.ts
│     ├─ cryptogram.util.ts
│     ├─ page.util.ts
│     └─ regex.util.ts
└─ modules
   └─ users // 示例业务模块 用户管理
      ├─ dto
      │  ├─ create-user.dto.ts
      │  ├─ list-user.dto.ts
      │  └─ update-user.dto.ts
      ├─ entities
      │  └─ user.entity.ts
      ├─ users.controller.ts
      ├─ users.module.ts
      └─ users.service.ts

TypeORM 集成

连接mysql数据库

为了与 SQL和 NoSQL 数据库集成,Nest 提供了 @nestjs/typeorm 包。Nest 使用TypeORM是因为它是 TypeScript 中最成熟的对象关系映射器( ORM )。因为它是用 TypeScript 编写的,所以可以很好地与 Nest 框架集成。

为了开始使用它,我们首先安装所需的依赖项。

$ npm install --save @nestjs/typeorm typeorm mysql2

安装过程完成后,我们可以将 TypeOrmModule 导入AppModule 。

app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'nestjs',
      autoLoadEntities: true, // 使用这个配置自动导入entities
      synchronize: true,
    }),
  ],
})
export class AppModule {}

因为我本地是开启热加载了,所以对于ormconfig.json这种方式是不支持的,如果你们那没有开启热加载可以试试以下方式:

我们可以创建 ormconfig.json ,而不是将配置对象传递给 forRoot()

{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "root",
  "database": "test",
  "entities": ["dist/**/*.entity{.ts,.js}"],
  "synchronize": true
}

然后,我们可以不带任何选项地调用 forRoot() :

app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [TypeOrmModule.forRoot()],
})
export class AppModule {}

静态全局路径(例如 dist/**/*.entity{ .ts,.js} )不适用于 Webpack 热重载。

增加环境配置

每个项目都有不用环境配置文件,这样我们切换环境修改一些配置的时候只取修改每个环境的配置文件即可。

src下新建目录config,在config下新建index.ts、env.development.ts、env.production.ts

env.development.ts // 开发环境配置

export default {
  // 服务基本配置
  SERVICE_CONFIG: {
    // 端口
    port: 3000,
  },

  // 数据库配置
  DATABASE_CONFIG: {
    type: 'mysql',
    host: 'localhost',
    port: 3306,
    username: 'root',
    password: 'root',
    database: 'nestjs',
    autoLoadEntities: true,
    synchronize: true,
  },
};

env.production.ts // 生产环境配置

export default {
  // 服务基本配置
  SERVICE_CONFIG: {
    // 端口
    port: 3000,
  },

  // 数据库配置
  DATABASE_CONFIG: {
    type: 'mysql',
    host: 'localhost',
    port: 3306,
    username: 'root',
    password: 'root',
    database: 'nestjs_prod',
    autoLoadEntities: true,
    synchronize: true,
  },
};

index.ts

import development from './env.development';
import production from './env.production';

const configs = {
  development,
  production,
};

const env = configs[process.env.NODE_ENV || 'development'];
export { env };

app.module.ts

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ValidationPipe } from './common/pipe/validate.pipe';
import { UsersModule } from './modules/users/users.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { env } from './common/config';
@Module({
  imports: [TypeOrmModule.forRoot(env.DATABASE_CONFIG), UsersModule],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

package.json

默认启动会走开发环境配置,如果想走生产环境配置,可添加NODE_ENV=production

"start:prod": "NODE_ENV=production nest start --watch"

封装CRUD

表设计、封装基本字段

一般数据库表设计的时候,都会有几个公共字段(主键id,创建人creator,创建时间createTime,更新人updater,更新时间updateTime,删除标志delFlag,更新次数version)。所以我们将这几公共字段封装一下,然后其他dto和entity来分别继承这个类。

提示:数据库表名和字段用小写命名,用下划线分隔,一般咱们设计删除数据的时候都是逻辑删除

分别新建文件 src=>commcon=>common=>entity=>base.entity.ts src=>commcon=>common=>dto=>base.dto.ts

base.entity.ts

import {
  Column,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
  CreateDateColumn,
  VersionColumn,
} from 'typeorm';

export abstract class Base {
  // 主键id
  @PrimaryGeneratedColumn()
  id: number;

  // 创建时间
  @CreateDateColumn({ name: 'create_time' })
  createTime: Date;

  @Column()
  // 创建人
  creator: string;

  // 更新时间
  @UpdateDateColumn({ name: 'update_time' })
  updateTime: Date;

  @Column()
  // 更新人
  updater: string;

  // 逻辑删除
  @Column({
    default: 0,
    select: false,
    name: 'del_flag',
  })
  delFlag: number;

  // 更新次数
  @VersionColumn({
    select: false,
  })
  version: number;
}

特殊列

有几种特殊的列类型可以使用:

  • @CreateDateColumn 是一个特殊列,自动为实体插入日期。无需设置此列,该值将自动设置。
  • @UpdateDateColumn 是一个特殊列,在每次调用实体管理器或存储库的save时,自动更新实体日期。无需设置此列,该值将自动设置。
  • @VersionColumn 是一个特殊列,在每次调用实体管理器或存储库的save时自动增长实体版本(增量编号)。无需设置此列,该值将自动设置。

base.dto.ts

import { ApiHideProperty } from '@nestjs/swagger';

export class BaseDTO {
  /**
   * 创建时间
   * @example Date
   */
  readonly createTime: Date;

  /**
   * 创建人
   * @example string
   */
  creator: string;

  /**
   * 更新时间
   * @example Date
   */
  readonly updateTime: Date;

  /**
   * 更新人
   * @example string
   */
  updater: string;

  /**
   * 是否删除
   * @example false
   */
  @ApiHideProperty()
  delFlag: number;

  /**
   * 更新次数
   * @example 1
   */
  @ApiHideProperty()
  version: number;
}

查询分页数据一般需要page第几页和pageSize每页数据条数,然后结果要返回pages总页数和total总条数还有records数据数组,新建文件src=>commcon=>common=>dto=>pagination.dto.ts

pagination.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { regPositiveOrEmpty } from 'src/common/utils/regex.util';

export class PaginationDTO {
  /**
   * 第几页
   * @example 1
   */
  @IsOptional()
  @Matches(regPositiveOrEmpty, { message: 'page 不可小于 0' })
  @ApiProperty({ description: 'page' })
  readonly page?: number;

  /**
   * 每页数据条数
   * @example 10
   */
  @IsOptional()
  @Matches(regPositiveOrEmpty, { message: 'pageSize 不可小于 0' })
  @ApiProperty({ description: 'pageSize' })
  readonly pageSize?: number;

  /**
   * 总页数
   * @example 10
   */
  pages: number;

  /**
   * 总条数
   * @example 100
   */
  total: number;

  // 数据
  records: any;
}

这里涉及一些正则校验,所以我们新建文件,预设一些正则表达式 src=>common=>utils=>regex.util.ts

regex.util.ts`

/**
 * 常用正则表达式
 */

// 非 0 正整数
export const regPositive = /^[1-9]\d*$/;

// 非 0 正整数 或 空
export const regPositiveOrEmpty = /\s*|^[1-9]\d*$/;

// 中国 11 位手机号格式
export const regMobileCN = /^1\d{10}$/g;

调整业务层

dao及entity继承base

create-user.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { BaseDTO } from 'src/common/common/dto/base.dto';

export class CreateUserDto extends BaseDTO {
  @ApiProperty({ description: '用户名', example: '用户' })
  userName: string;

  @ApiProperty({ description: '真实姓名' })
  realName: string;

  @ApiProperty({ description: '密码' })
  password: string;

  @ApiProperty({ description: '性别 0:男 1:女 2:保密' })
  gender: number;

  @ApiProperty({ description: '邮箱' })
  email: string;

  @ApiProperty({ description: '手机号' })
  mobile: string;

  @ApiProperty({ description: '部门ID' })
  deptId: string;

  @ApiProperty({ description: '状态: 0启用 1禁用' })
  status: number;
}

user.entity.ts

import { Base } from 'src/common/common/entity/base.entity';
import { Entity, Column } from 'typeorm';

@Entity('user')
export class User extends Base {
  @Column({ name: 'user_name' })
  userName: string;

  @Column({ name: 'real_name' })
  realName: string;

  @Column()
  password: string;

  @Column()
  gender: number;

  @Column()
  email: string;

  @Column()
  mobile: string;

  @Column({ name: 'dept_id' })
  deptId: string;

  @Column({ default: 0 })
  status: number;
}

调整service

users.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { getPagination } from 'src/common/utils/page.util';
import { Not, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUserDto } from './dto/list-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { sourceToTarget } from 'src/common/utils/convert.utils';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  // 新增
  async create(createUserDto: CreateUserDto): Promise<void> {
    // 由于
    createUserDto.creator = 'admin';
    createUserDto.updater = 'admin';
    await this.usersRepository.save(createUserDto);
  }

  // 查询分页
  async findAll(params): Promise<ListUserDto> {
    const { page = 1, pageSize = 10 } = params;
    const getList = this.usersRepository
      .createQueryBuilder('user')
      .where({ delFlag: 0 })
      .orderBy({
        'user.update_time': 'DESC',
      })
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount();

    const [list, total] = await getList;
    const pagination = getPagination(total, pageSize, page);
    return {
      records: list,
      ...pagination,
    };
  }

  // 根据id查询信息
  async findOne(id: string): Promise<User> {
    return await this.usersRepository.findOne(id);
  }

  // 根据id或id和userName查询信息
  async findByName(userName: string, id: string): Promise<User> {
    const condition = { userName: userName };
    if (id) {
      condition['id'] = Not(id);
    }
    return await this.usersRepository.findOne(condition);
  }

  // 更新
  async update(updateUserDto: UpdateUserDto): Promise<void> {
    const user = sourceToTarget(updateUserDto, new UpdateUserDto());
    await this.usersRepository.update(user.id, user);
  }
}

暂时写的增删改查的基本方法,因为是逻辑删除,所以执行更新操作即可。如果大家还有一些基于数据库表操作的语句想要深入了解,可以看看TypeORM的官方网站描述。

下面详细说一下分页这块,因为新增和修改都是单独的dto,他们之间的字段是有区别的,那么分页的接收参数和返回的数据我们可以也写一个的dto。

list-user.dto.ts

import { ApiProperty, PartialType } from '@nestjs/swagger';
import { PaginationDTO } from 'src/common/common/dto/pagination.dto';

export class ListUserDto extends PartialType(PaginationDTO) {
  @ApiProperty({ description: '用户名', required: false })
  userName?: string;
}

继承了PaginationDTO,然后在里面可以自定义一些查询参数 所以这样的话我们分页返回的数据格式就是

{
    total: 0,
    page: 0,
    pageSize: 10,
    pages: 0,  
    records: []
}

在分页里我们还需要一个封装法法,根据当前页和总数和每页的数量求共有多少页 src=>common=>utils=>index.util.ts

index.util.ts

/**
 * 获取分页信息
 * @param total
 * @param pageSize
 * @param page
 * @returns
 */
export const getPagination = (
  total: number,
  pageSize: number,
  page: number,
) => {
  const pages = Math.ceil(total / pageSize);
  return {
    total: Number(total),
    page: Number(page),
    pageSize: Number(pageSize),
    pages: Number(pages),
  };
};

调整controller

users.controller.ts

import {
  Controller,
  Get,
  Post,
  Body,
  Param,
  Delete,
  Query,
  Put,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ListUserDto } from './dto/list-user.dto';
import { Result } from 'src/common/common/dto/result.dto';
import { ErrorCode } from '../../common/exception/error.code';

@Controller('users')
@ApiTags('用户管理')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ApiOperation({ summary: '新增用户信息' })
  async create(@Body() createUserDto: CreateUserDto) {
    const user = this.usersService.findByName(createUserDto.userName, '');
    if (user) {
      return new Result().error(
        new ErrorCode().INTERNAL_SERVER_ERROR,
        '用户名已存在',
      );
    }
    await this.usersService.create(createUserDto);
    return new Result().ok();
  }

  @Get()
  @ApiOperation({ summary: '查询用户列表' })
  async findAll(@Query() listUserDto: ListUserDto) {
    const userList = await this.usersService.findAll(listUserDto);
    return new Result<UpdateUserDto>().ok(userList);
  }

  @Get(':id')
  @ApiOperation({ summary: '查询用户信息' })
  async findOne(@Param('id') id: string) {
    const user = await this.usersService.findOne(id);
    return new Result<UpdateUserDto>().ok(user);
  }

  @Put(':id')
  @ApiOperation({ summary: '修改用户信息' })
  async update(@Body() updateUserDto: UpdateUserDto) {
    const user = this.usersService.findByName(
      updateUserDto.userName,
      updateUserDto.id + '',
    );
    if (user) {
      return new Result().error(
        new ErrorCode().INTERNAL_SERVER_ERROR,
        '用户名已存在',
      );
    }
    await this.usersService.update(updateUserDto);
    return new Result().ok();
  }

  @Delete(':id')
  @ApiOperation({ summary: '删除用户信息' })
  async remove(@Param('id') id: string) {
    const user = await this.usersService.findOne(id);
    if (!user) {
      return new Result().error(
        new ErrorCode().INTERNAL_SERVER_ERROR,
        '用户不存在',
      );
    }
    user.delFlag = 1;
    await this.usersService.update(user);
    return new Result().ok();
  }
}

在这里我并没有采用拦截器和过滤器去统一返回数据的格式,拦截器和过滤器我更倾向于去做一些大的东西,全局的一些过滤和拦截,还有就是还没有深入研究这两个东西的使用,所以这里我封装了返回类。 src=>common=>common=>dto=>result.dto.ts

result.dto.ts

export class Result<T> {
  // 状态码
  code: number;

  // 请求结果信息
  message: string;

  // 数据
  data: T;

  ok(data = null, message = 'success') {
    this.code = 0;
    this.data = data;
    this.message = message;
    return this;
  }

  error(code = 1, message = 'error') {
    this.code = code;
    this.message = message;
    return this;
  }
}

封装了ok成功和error失败的方法,成功的时候有可能会传data数据和message信息,失败的时候回传code和message信息,并且封装了错误返回码类 src=>common=>exception=>error.code.ts

error.code.ts

/**
 * 错误编码,由5位数字组成,前2位为模块编码,后3位为业务编码
 * 如:10001(10代表系统模块,001代表业务代码)
 */
export class ErrorCode {
  INTERNAL_SERVER_ERROR = 500;
  UNAUTHORIZED = 401;
  FORBIDDEN = 403;

  NOT_NULL = 10001;
  DB_RECORD_EXISTS = 10002;
  PARAMS_GET_ERROR = 10003;
  ACCOUNT_PASSWORD_ERROR = 10004;
  ACCOUNT_DISABLE = 10005;
  IDENTIFIER_NOT_NULL = 10006;
  CAPTCHA_ERROR = 10007;
  SUB_MENU_EXIST = 10008;
  PASSWORD_ERROR = 10009;
  ACCOUNT_NOT_EXIST = 10010;
}

小结

到目前为止,mysql数据库连接以及一些基础的封装以及搞定,运行程序试试吧。

如果大家需要添加一些拦截器和过滤器可以接着查看一下章节。

拦截器

拦截器我只写了一部分,如果大家需要可以去查看nestjs官方网站文档

响应映射

我们已经知道, handle() 返回一个 Observable。此流包含从路由处理程序返回的值, 因此我们可以使用 map() 运算符轻松地对其进行改变。

响应映射功能不适用于特定于库的响应策略(禁止直接使用 @Res() 对象)。

让我们创建一个 TransformInterceptor, 它将打包响应并将其分配给 data 属性。

transform.interceptor.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(map(data => ({ data })));
  }
}

main.ts

async function bootstrap() {
    ...
    app.useGlobalInterceptors(new TransformInterceptor());
    ...
}

过滤器

异常过滤器

虽然基本(内置)异常过滤器可以为您自动处理许多情况,但有时您可能希望对异常层拥有完全控制权,例如,您可能希望基于某些动态因素添加日志记录或使用不同的 JSON 模式。 异常过滤器正是为此目的而设计的。 它们使您可以控制精确的控制流以及将响应的内容发送回客户端。

让我们创建一个异常过滤器,它负责捕获作为HttpException类实例的异常,并为它们设置自定义响应逻辑。为此,我们需要访问底层平台 Request和 Response。我们将访问Request对象,以便提取原始 url并将其包含在日志信息中。我们将使用 Response.json()方法,使用 Response对象直接控制发送的响应。

http-exception.filter.ts

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

所有异常过滤器都应该实现通用的 ExceptionFilter<T> 接口。它需要你使用有效签名提供 catch(exception: T, host: ArgumentsHost)方法。T 表示异常的类型。

@Catch() 装饰器绑定所需的元数据到异常过滤器上。它告诉 Nest这个特定的过滤器正在寻找 HttpException 而不是其他的。在实践中,@Catch() 可以传递多个参数,所以你可以通过逗号分隔来为多个类型的异常设置过滤器。

main.ts

async function bootstrap() {
    ...
    app.useGlobalFilters(new HttpExceptionFilter());
    ...
}

总结

以上就是这次项目框架优化的内容,虽然这些只是基本的一些封装,也总算像个样子了,下一篇打算做下用户的登录等功能,结合着vue3架子搭建的后台管理前端页面,做一些实际的业务东西。

代码地址:gitee.com/wd_591/nest…

本文参考:juejin.cn/column/6992…