最近一个公司官网需要做后台管理,自告奋勇伸出手接下这活。我本来计划技术栈是 Nestjs
+ MongoDB
,看我的github的人应该发现,我只会这个。和运维一番沟通后,他说不支持 MongoDB
,仅支持 Mysql
。
第一次使用
Mysql
这是一段神奇的开始...
在 nestjs 官网文档有个专门的 database 板块。
首推就是 Typeorm ,这篇也算是一个入门教程。(ps:里面也有无尽的坑)
nestjs 也有其他几个操作数据库的的 orm:
以上都是操作 Mysql
的特有 orm,有些 nestjs 做了专门集成封装模块,方便使用。
既然官网教程首推 Typeorm
,那我们就用上。
我电脑里面装了一个 Navicat Premium
,可以可视化多种数据的图形化界面。
关于 Mysql
,你可以选择 Docker
安装,也可以直接下载安装文件安装。推荐 Docker
。
本来我也打算 Docker
安装的,运维给我了一个服务器的 Mysql
的地址和账号密码。那就直接连接就行了。
因为不会 Mysql
语句,那就傻瓜式图形界面创建数据库吧。
也不知道怎么创建,好歹公司后台都是Java,用的全是 Mysql
,找个人问下,就解决问题。
图形化界面可以自动生成 Mysql
语句:
CREATE DATABASE `test` CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_unicode_ci';
连接远程 Mysql
搞定,创建数据库搞定,接下来就是程序连接和建表操作。
根据 nestjs 官网文档,一顿操作下来完美连接运行。
第一个坑,自动建表
关于 Mysql
的表,在 Typeorm
对应叫 Entity
。 Entity
里面字段列和数据库里面的是一一对应的。
换句话来说,在数据库里面建表,要么手动建,设计表结构,另外一种就是 Typeorm
帮我们自动建。
手动建,我肯定搞不懂,自动建那就比较简单,只需要看
Typeorm
文档即可。
Typeorm
载入 Entity
有三种方式:
单独定义
import { User } from './user/user.entity';
TypeOrmModule.forRoot({
//...
entities: [User],
}),
用到哪些实体,就逐一在此处引入。缺点就是我们每写一个实体就要引入一次否则使用实体时会报错。
这里需要说一下,我用的 Nx 这个工具,它做
nodejs
打包用的是webpack
,意思就是说会打包到一个main.js
。我只能使用这种模式。
自动加载
TypeOrmModule.forRoot({
//...
autoLoadEntities: true,
}),
自动加载我们的实体,每个通过 TypeOrmModule.forFeature()
注册的实体都会自动添加到配置对象的 entities
数组中, TypeOrmModule.forFeature()
就是在某个 service
中的 imports
里面引入的,这个是比较推荐。
自定义引入路径
TypeOrmModule.forRoot({
//...
entities: ['dist/**/*.entity{.ts,.js}'],
}),
这是官方推荐的方式。
自动建表还有一个配置需要设置:
TypeOrmModule.forRoot({
//...
entities: ['dist/**/*.entity{.ts,.js}'],
synchronize: true,
}),
问题就处在 synchronize: true
上,自动建表,你修改 Entity
里面字段,或者 *.entity{.ts,.js}
的名字,都会自动帮你修改。
警告:线上一定要关了,不然直接提桶跑路,别挣扎了。
正确姿势是使用 typerom migration
方案:
migrations 会每次记录数据库更改的版本及内容,以及如何回滚,对于数据处理的更多策略就需要团队根据需求去开发。同时修改的entity 保证新的开发人员可以无需 migrations 即可直接使用。
nestjs 使用 migration 很麻烦,所以官网文档里面都没有写,migrations,大写的懵逼。
migrations
把放在 TypeOrmModule.forRoot
里的配置独立出来 ormconfig.ts
//
export const config: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.host,
port: parseInt(process.env.port),
username: process.env.username,
password: process.env.password,
database: process.env.schema,
entities: [User], // 也可以使用: [__dirname + '/**/*.entity.{ts, js}']
// 根据自己的需求定义,migrations
migrations: [UserInitialState],// 也可以使用: ['src/migration/*{.ts,.js}']
cli: {
migrationsDir: 'src/migration'
},
synchronize: true,
}
注意:这里不能使用
@nestjs/config
模块动态获取,需要使用process.env
去获取。
建立 cli 配置 ormconfig-migrations.ts
import {config} from './ormconfig';
export = config;
TypeOrmModule.forRoot
里引入 ormconfig.ts
配置
import {config} from './ormconfig';
TypeOrmModule.forRoot(config);
在 package.json
里面增加 scripts
:
...
"typeorm:cli": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -f ./ormconfig-migrations.ts",
"migration-generate": "npm run typeorm:cli -- migration:generate -n"
"migration-run": "npm run typeorm:cli -- migration:run -n"
然后就可以愉快的玩耍了。
第二个坑,自增主键
在 Typeorm
提供的主键的装饰器 PrimaryGeneratedColumn
,里面支持四种模式:
- increment (默认)
- uuid(Typeorm 帮我们自动添加)
- rowid
- identity
基本所有教程文章都是用默认的 increment
。
然后问题就出现了,使用 increment
在插入数据会出现错误:
Typeorm error 'Cannot update entity because entity id is not set in the entity.'
这个问题困扰我很久,搜索这个问题,也没有得到最终的解答
一开始找到的答案是 .save(entity, {reload: false})
满心欢喜插入了数据库,发现数据库里面的数据 id
是 0
。
一开始不懂为什么,按道理我设置自增id,起始位置1开始,那么第一条应该是1才对,应该这个是不对的。
我又插入一条数据:
Mysql error ‘Duplicate entry '0' for key 'PRIMARY'
问题原因:我用的 int
,它的默认值就是 0。为什么每次会插入默认值。
带着这个疑惑,寻找解决方案,配置里面有个 logging: true,
我把它打开,可以输出执行的 Mysql
语句。
然后使用 .save(entity, {reload: false})
插入数据:
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
虽然看不懂是什么,大概理解一下,第一个括号插入的字段名,第二括号就是对应的值,DEFAULT
就是 Mysql
默认值,也就是我们设置的 default
属性。?
就和后面的参数一一对应。
既然 Typeorm
插入有问题,那我是不是可以直接用 Mysql
语句插入,就算玩挂了,也就是一个删库跑路。
使用 Navicat Premium
执行 Mysql
,网上找了一下简单的 Mysql
语句:
- 显示所有数据表
show databases;
- 切换指定数据表
use test
# Database changed 表示成功
和
MongoDB
操作差不多。
然后我在执行插入语句:
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (DEFAULT, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
还是一样报错 ‘Duplicate entry '0' for key 'PRIMARY'
思考:id 是自增的应该不需要传递 id,这个字段吧。带着个这个猜想:
INSERT INTO `users`(`username`, `password`, `created_at`, `updated_at`) VALUES (?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
成功插入数据,真是激动万分。
这锅就是 Typeorm
的坑了。
那需要解决问题, Typeorm
提供的可以直接写语句的 query
,对于我这种完全不会人肯定无法搞定,那就换个思路解决。
Typeorm
会自动给 id
一个默认值 DEFAULT
, Mysql
就会给它默认一个 0。那如果我不设置默认, Mysql
应该没有 undefined
,这种玩意,但是有一个 null
,和 js
意思一样,都表示空,那我给 id
设置 null
。
INSERT INTO `users`(`id`, `username`, `password`, `created_at`, `updated_at`) VALUES (null, ?, ?, DEFAULT, DEFAULT) --PARAMETERS: ["jiayi", "123456"]
又成功插入数据。
意思就是说我在 .save(entity, {reload: false})
插入数据之前,设置 entity.id = null
即可。
每次创建都是去设置太麻烦了,
@Entity('users')
export class User {
@PrimaryGeneratedColumn({
type: 'int',
})
id: number = null;
...
}
给 Entity
类型,设置默认值,这个默认值和数据库 default
是有区别的,这是实例属性值。
最后发现设置默认值 null
,不光解决 Mysql
语句重复添加问题,还解决了 Typeorm
报错问题。
Typeorm
插入最终都会 github.com/typeorm/typ… 里的 ReturningResultsEntityUpdator.insert
方法:
这是错误来源代码:
const entityIds = entities.map((entity) => {
const entityId = metadata.getEntityIdMap(entity)!
// We have to check for an empty `entityId` - if we don't, the query against the database
// effectively drops the `where` clause entirely and the first record will be returned -
// not what we want at all.
if (!entityId)
throw new TypeORMError(
`Cannot update entity because entity id is not set in the entity.`,
)
return entityId
})
通过 github.com/typeorm/typ… 里的 EntityMetadata.getValueMap()
静态方法获取。
在通过 github.com/typeorm/typ… 里的 ColumnMetadata.getEntityValueMap()
实例方法:
if() {}
else {
if () {}
else {
// 如果不设置 null ,默认就直接 undefined
if (entity[this.propertyName] !== undefined && (returnNulls === false || entity[this.propertyName] !== null))
return { [this.propertyName]: entity[this.propertyName] };
return undefined;
}
}
设置默认值实例属性
id = null
最终就解决报错问题。
第三个坑,版本升级
在 @nestjs/typeorm 时,遇到一个版本升级问题,本来我用的 Release 8.0.4,升级以后变成 Release 8.1.0
按照文档安装:
{
...
"@nestjs/typeorm": "^8.0.4",
..
}
简单科普一下语义版本的书写规则:
符号 | 描述 | 示例 | 示例描述 |
---|---|---|---|
> | 大于某个版本 | >1.2.1 | 大于1.2.1版本 |
>= | 大于等于某个版本 | >=1.2.1 | 大于等于1.2.1版本 |
< | 小于某个版本 | <1.2.1 | 小于1.2.1版本 |
<= | 小于等于某个版本 | <=1.2.1 | 小于等于1.2.1版本 |
- | 介于两个版本之间 | 1.2.1 - 1.4.5 | 介于1.2.1和1.4.5之间 |
x | 不固定的版本号 | 1.3.x | 只要保证主版本号是1,次版本号是3即可 |
~ | 补丁版本号可增 | ~1.3.4 | 保证主版本号是1,次版本号是3,补丁版本号大于等于4 |
^ | 此版本和补丁版本可增 | ^1.3.4 | 保证主版本号是1,次版本号可以大于等于3,补丁版本号可以大于等于4 |
* | 最新版本 | * | 始终安装最新版本 |
x.y.z | 固定的版本号 | 1.3.3 | 保证固定的版本号 |
这样在自己本地开发项目很正常,一旦同事协助开发或者发布构建就直接挂了。问题就出在 8.1.0
的依赖 typeorm@v0.3+
属于一个破坏性更新。
如果想要项目正常运行,就需要锁版本:
{
...
"@nestjs/typeorm": "8.0.4",
..
}
说几个大的改动:
实体引入问题
前面介绍了,实体引入方式,现在做了限制:
...
entities: [User], // 不支持使用: [__dirname + '/**/*.entity.{ts, js}']
migrations: [UserInitialState],// 不支持使用: ['src/migration/*{.ts,.js}']
不过 nestjs
做了一些特殊处理,有一个 autoLoadEntities
配置,可以帮我们把注册的 TypeOrmModule.forFeature([EntityClassOrSchema])
自动注入到 entities
。
自定义 Repository
这是一个很常见的需求。内置的通用方法满足不了我们,就需要我们单独定制。因为改动比较大, nestjs
官网直接跳过了。
我们先看看改版之前的写法:
user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column({ default: true })
isActive: boolean;
}
user.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { User } from './user.entity';
@EntityRepository(User)
export class UserRepository extends Repository<User> {
getUserById(id: number) {
// code
}
}
user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserRepository } from './user.repository';
@Module({
imports: [TypeOrmModule.forFeature([UserRepository])],
exports: [TypeOrmModule],
})
export class UserModule {}
user.service.ts
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Injectable()
export class UserService {
constructor(private userRepository: UserRepository) {}
getUserById(id: number) {
return this.userRepository.getUserById(id);
}
}
EntityRepository
被弃用了。上面写法就直接废了。TypeOrmModule.forFeature([EntityClassOrSchema])
现在要求传递@Entity()
装饰class
或者 使用EntitySchema
创建。
接下来就提供几种替代方案:
方案1 - 自定义装饰器+模块
import { SetMetadata } from "@nestjs/common";
import { DynamicModule, Provider } from "@nestjs/common";
import { getDataSourceToken } from "@nestjs/typeorm";
import { DataSource, EntitySchema } from "typeorm";
export const TYPEORM_CUSTOM_REPOSITORY = "TYPEORM_EX_CUSTOM_REPOSITORY";
export function CustomRepository(entity: Function | EntitySchema<any>): ClassDecorator {
return SetMetadata(TYPEORM_CUSTOM_REPOSITORY, entity);
}
export class TypeOrmRepositoryModule {
public static forCustomRepository<T extends new (...args: any[]) => any>(repositories: T[]): DynamicModule {
const providers: Provider[] = [];
for (const repository of repositories) {
const entity = Reflect.getMetadata(TYPEORM_CUSTOM_REPOSITORY, repository);
if (!entity) {
continue;
}
providers.push({
inject: [getDataSourceToken()],
provide: repository,
useFactory: (dataSource: DataSource): typeof repository => {
const baseRepository = dataSource.getRepository<any>(entity);
return new repository(baseRepository.target, baseRepository.manager, baseRepository.queryRunner);
},
});
}
return {
exports: providers,
module: TypeOrmRepositoryModule,
providers,
};
}
}
使用 CustomRepository
代替 EntityRepository
:
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { CustomRepository } from '@CustomRepository';
@CustomRepository(User)
export class UserRepository extends Repository<User> {
getUserById(id: number) {
// code
}
}
user.module.ts
里面不能使用 UserRepository
,注册 User
:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
exports: [TypeOrmModule],
})
export class UserModule {}
在 AppModule
里注册 UserRepository
:
import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { TypeOrmRepositoryModule } from '@CustomRepository';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mssql',
...
entities: [User],
}),
TypeOrmRepositoryModule.forCustomRepository([UserRepository]),
...
],
})
export class AppModule { }
在 user.service.ts
使用不变。
方案2 - 依赖注入+DataSource
这个方案不需要新加入装饰器和模块,看看我们如何改造一下:
import { Injectable } from '@nestjs/common';
import { User } from './user.entity';
@Injectable()
export class UserRepository {
constructor(private dataSource: DataSource) { }
getUserById(id: number) {
const Repository = this.dataSource.getRepository(User);
// code
}
}
以前还有一个 this.manager.getCustomRepository
方法,获取其他 CustomRepository
,现在已经废弃了,这样就可以更简单直接依赖注入就行了,怎么注入下面会介绍。
user.module.ts
需要在模块里注册依赖 UserRepository
,并且导出:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserRepository],
exports: [TypeOrmModule, UserRepository],
})
export class UserModule {}
在 user.service.ts
使用需要改变一下
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Injectable()
export class UserService {
constructor(
@Inject(UserRepository)
private readonly userRepository: UserRepository,
) {}
getUserById(id: number) {
return this.userRepository.getUserById(id);
}
}
在 UserRepository 引入其他 CustomRepository 也需要像现在这样的依赖注入,还需要注意要在模块里导入依赖的模块,不然会抛出没有依赖的错误。
这种方式比一种精简不少,改动也相对来说比较小,但有个问题是它改动业务代码写法,我们来尝试改进一下。
方案3 - 依赖注入+DataSource改进版
import { Injectable } from '@nestjs/common';
import { User } from './user.entity';
@Injectable()
export class UserRepository extends Repository<Article> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
}
getUserById(id: number) {
// code
}
}
模块注册依赖和第2方案一样,服务里写法不需要修改,我们这算是最小改动。
还有一种改动,也是 typeorm
推荐的 dataSource.getRepository(Product).extend
, 通过 extend
来自实现 CustomRepository
。
也许你还会想到其他方案,可以和我交流。
写在最后
无论使用什么技术都没有一帆风顺的,总是有无尽的坑需要填,各方面原因凑在一起就引起未知的坑,我们需要掌握排坑技巧,不断提升解决问题的能力。
今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情:
- 找到错字了?下面评论
- 如果有问题吗?下面评论
- 对你有用吗?表达你的支持并分享它。