在了解完 NestJS 的基础配置之后,服务端的内容将引来一个比较重要的环节:数据库。
因为数据库的内容比较多,所以相关内容将分为两个章节来展开讨论:
- 数据库工具封装 - 将封装统一的数据库操作工具类,方便后期开发于集成
- 数据库实操 - 结合实际项目讲述 TypeORM 的使用以及 MySQL 与 Mongoose 的示例
TypeORM
日常对数据库的操作需要借助于 SQL
,至少需要掌握基础的 SQL
语法就有建表、增删改查等。但如果想要在代码中直接实现对数据库的操作,就需要去写大量 SQL
,这在可读性、维护性及开发体验上都非常糟糕。
于是 ORM 框架应运而生,这类的框架是为了解决面向对象与关系数据库存在的互不匹配的现象,把面向 SQL
开发转变为面向对象开发,开发不需要关注底层实现细节,而是以操作对象的模式使用数据库。
虽然市面上也有其他不错的 ORM
框架,比如 Sequelize、Prisma 等,但 TypeORM
使用 TypeScript
编写,在 NestJS
框架下运行得非常好,也是 NestJS
首推的 ORM
框架,有开箱即用的 @nestjs/typeorm
软件包支持。
这一章对于很多偏前端的同学来说,会稍微有点复杂,但借助于 ORM 框架来说,并非是无从下手。
封装
NestJS
使用 TypeORM
的方式有两种。一种是 NestJS
提供的 @nestjs/typeorm
集成包,可以导出 TypeOrmModule.forRoot
方法来连接数据库,同时可以使用 ormconfig.json
将数据库链接配置项剥离。另外一种是直接使用 typeorm
,自由封装 Providers
导入使用。
两种方案各有优缺点,使用 @nestjs/typeorm
集成的方案较为简便,但自建的业务脚手架需要两种数据库保证在开发中体验一致性,此外之前已经自定义了全局环境变量的配置,没有必要再多一个 ormconfig.json
的配置来增加额外理解成本,所以接下来我们将使用第二种方案来连接数据库。
由于我们已经采用了 Monorepo 的开发模式且已经使用了 Lib,所以将封装工具类全部收敛进 libs/comm
,所以这一步开始都是基于 libs/comm
工具库。
第一步:跟之前一样,为了使用 TypeORM
,先安装以下依赖:
$ pnpm add typeorm mysql2 mongoose -w
第二步:在 dev.yaml
中添加数据库配置参数。
MONGODB_CONFIG:
name: "ignition_test" # 自定义次数据库链接名称
type: mongodb # 数据库链接类型
url: "mongodb://localhost:27017" # 数据库链接地址
username: "root" # 数据库链接用户名
password: "123456" # 数据库链接密码
database: "fast_gateway_test" # 数据库名
entities: "mongo" # 自定义加载类型
logging: false # 数据库打印日志
synchronize: true # 是否开启同步数据表功能
MYSQL_CONFIG:
name: "material_test"
type: "mysql"
host: "121.36.198.10"
port: 3306
username: "root"
password: "123456"
database: "material_test"
entities: "mysql"
logging: true
synchronize: true
以上是数据库连接的必要参数,其他的参数可以参考文档根据需求添加,例如 retryAttempts
(重试连接数据库的次数)、keepConnectionAlive
(应用程序关闭后连接是否关闭) 等配置项。
MongoDB 是无模式的,所以即使在配置参数开启了
synchronize
,启动项目的时候也不会去数据库创建对应的表,所以不用奇怪,并没有出错,但Mysql
在每次应用程序启动时自动同步表结构。为了避免意外synchronize
这个配置参数一定不要在生产环境开启,每次服务启动的时候都会同步数据库表结构,如果出现主键不同、表结构不等的情况下,会直接进行数据库删表操作,生产环境一定要关闭!
第三步:新建 lib/comm/src/database/database.providers.ts
import { DataSource } from 'typeorm';
import { getConfig } from '../utils/index';
import { NamingStrategy } from './naming.strategies';
const { MONGODB_CONFIG, MYSQL_CONFIG } = getConfig();
const MONGODB_DATABASE_CONFIG = {
...MONGODB_CONFIG,
entities: [`dist/**/*.${MONGODB_CONFIG.entities}.entity.js`]
};
const MYSQL_DATABASE_CONFIG = {
...MYSQL_CONFIG,
namingStrategy: new NamingStrategy(),
entities: [`dist/**/*.${MYSQL_CONFIG.entities}.entity.js`]
};
const MONGODB_DATA_SOURCE = new DataSource(MONGODB_DATABASE_CONFIG);
const MYSQL_DATA_SOURCE = new DataSource(MYSQL_DATABASE_CONFIG);
// 数据库注入
export const DatabaseProviders = [
{
provide: 'MONGODB_DATA_SOURCE',
useFactory: async () => {
if (!MONGODB_DATA_SOURCE.isInitialized) await MONGODB_DATA_SOURCE.initialize();
return MONGODB_DATA_SOURCE;
},
},
{
provide: 'MYSQL_DATA_SOURCE',
useFactory: async () => {
if (!MYSQL_DATA_SOURCE.isInitialized) await MYSQL_DATA_SOURCE.initialize();
return MYSQL_DATA_SOURCE;
},
},
];
注意:创建的实体类文件命名后缀统一为 entity.ts
,但为了区分不同的数据库扫描,加了 MYSQL_CONFIG.entities
来区分不同的数据库类型,同样当我们需要使用多数据库的时候,可以依照这种模式来新增不同的数据库。
其中针对于 MySQL 理论上都需要遵守驼峰命名规范,需要对一些不太规范的实体类名进行转换,所以会比 Mongoose 对一个配置 naming.strategies.ts
:
/*
* @Author: Cookie
* @Description: 添加数据库表与字段驼峰转下划线功能
*/
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { snakeCase } from 'typeorm/util/StringUtils';
export class NamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface {
tableName(className: string, customName: string): string {
return customName ? customName : snakeCase(className);
}
columnName(
propertyName: string,
customName: string,
embeddedPrefixes: string[],
): string {
return (
snakeCase(embeddedPrefixes.concat('').join('_')) +
(customName ? customName : snakeCase(propertyName))
);
}
relationName(propertyName: string): string {
return snakeCase(propertyName);
}
joinColumnName(relationName: string, referencedColumnName: string): string {
return snakeCase(relationName + '_' + referencedColumnName);
}
joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
secondPropertyName: string,
): string {
return snakeCase(
`${firstTableName}_${firstPropertyName.replace(
/\./gi,
'_',
)}_${secondTableName}`,
);
}
joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return snakeCase(
tableName + '_' + (columnName ? columnName : propertyName),
);
}
classTableInheritanceParentColumnName(
parentTableName: any,
parentTableIdPropertyName: any,
): string {
return snakeCase(parentTableName + '_' + parentTableIdPropertyName);
}
eagerJoinRelationAlias(alias: string, propertyPath: string): string {
return alias + '__' + propertyPath.replace('.', '_');
}
}
第四步:新建 database.module.ts
import { Global, Module } from '@nestjs/common';
import { DatabaseProviders } from './database.providers';
@Global()
@Module({
providers: [...DatabaseProviders],
exports: [...DatabaseProviders],
})
export class DatabaseModule { }
至此我们已经封装了 MongoDB
与 MySQL
的 Provider
,作为统一的数据库操作类提供给其他的服务调用,但这其中也有一些缺陷,例如实体类的注册是依赖于静态路径收集注册,也就是采用此方式的话,不太适用于 Webpack 热更新与 Monorep 的方案,所以想使用其他方案的话,就不要采取这个模式。
使用
从这一步开始都是基于 app/low-code-test
实际服务端项目。
第一步:注册实体,创建 src/user/user.mongo.entity.ts
import { Entity, Column, ObjectIdColumn } from 'typeorm';
@Entity()
export class User {
@ObjectIdColumn()
id?: number;
@Column({ default: null })
name: string;
}
在 MongoDB
里面使用的是 ObjectIdColumn
作为类似 MySQL
的自增主键,来保证数据唯一性,只是类似,并不是跟普通自增主键一样会递增,把它看成 uuid
类似即可。
第二步:创建 user.providers.ts
:
import { User } from './user.mongo.entity';
export const UserProviders = [
{
provide: 'USER_REPOSITORY',
useFactory: async (AppDataSource) => await AppDataSource.getRepository(User),
inject: ['MONGODB_DATA_SOURCE'],
},
];
第三步:创建 user.service.ts
,新增添加用户 service
:
import { In, Like, Raw, MongoRepository } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import { User } from './user.mongo.entity';
@Injectable()
export class UserService {
constructor(
@Inject('USER_REPOSITORY')
private userRepository: MongoRepository<User>
) { }
createOrSave(user) {
return this.userRepository.save(user)
}
}
第四步:创建 user.dto.ts
,插件
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class AddUserDto {
@ApiProperty({ example: 123, })
id?: string;
@ApiProperty({ example: 'cookie' })
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'cookieboty@qq.com' })
@IsNotEmpty()
email: string;
@ApiProperty({ example: 'cookieboty' })
@IsNotEmpty()
username: string;
}
有的同学可能会问,DTO(Data Transfer Object) 与 Entities 的区别,毕竟两个文件都很类似。
首先它们都用于表示数据,但在设计和用途方面有所不同:
-
DTO 是一种数据传输对象,用于在不同的层之间传输数据。它通常用于将数据从数据库层传输到应用程序层,或将数据从应用程序层传输到前端层。DTO 的设计目的是为了最大程度地减少数据传输的开销,通常只包含必要的数据字段,而不包含任何业务逻辑或操作方法。
-
Entities 是一种实体对象,用于表示应用程序中的业务对象或领域对象。它通常用于表示数据库中的表或文档,或者表示应用程序中的业务对象。Entities 的设计目的是为了封装业务逻辑和操作方法,以便在应用程序中进行操作和处理。
第五步:创建 user.controller.ts
,添加新增用户的 http
请求方法:
import { Controller, Post, Body, Query, Get } from '@nestjs/common';
import { UserService } from './user.service';
import { AddUserDto } from './user.dto';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('用户')
@Controller('user')
export class UserController {
constructor(
private readonly userService: UserService,
) { }
@ApiOperation({
summary: '新增用户',
})
@Post('/add')
create(@Body() user: AddUserDto) {
return this.userService.createOrSave(user);
}
}
第六步:创建 user.module.ts
,将 controller
、providers
、service
等都引入后,切记将 user.module.ts
导入 app.module.ts
后才会生效,这一步别忘记了 :
import { Module } from '@nestjs/common';
import { DatabaseModule } from '@/common/database/database.module';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserProviders } from './user.providers';
import { FeishuController } from './feishu/feishu.controller';
import { FeishuService } from './feishu/feishu.service';
@Module({
imports: [
DatabaseModule
],
controllers: [
FeishuController,
UserController
],
providers: [...UserProviders, UserService, FeishuService],
exports: [UserService],
})
export class UserModule { }
完成上述所有步骤之后,此时打开 Swagger
文档可以看到,已经创建好了 /api/user/add
新增用户的 http
接口:
点击测试能正常得到如下返回值的话,则代表数据插入成功,功能正常:
查询数据库所得如下所示:
写在最后
本章主要介绍了如何封装一个数据库操作类,与直接使用 NestJS 自带的 TypeORM 工具库不同的是,我们是自己封装了一套,这样的好处是自定义程度会更高,但与之而来就是很多特性我们也就无法再使用,如果有需求的话就需要自己重新开发。
如果不喜欢自己折腾的话,可以看下 @nestjs/typeorm
的使用,结合 NetsJS 的官方文档上手也不慢。
但对于小册来说,我希望带来的是不一样的视角与实战的经验而不是文档的转述与解读,有一定自学能力的同学其实看文档也就足够了。
无论选择哪一种方案,下一章,我们将学习数据库实操的相关内容。
如果你有什么疑问,欢迎在评论区提出。 👏
15 服务端实战:数据库工具封装