1 背景介绍
TypeORM 是非常流行的ORM库,是我们使用数据库的利器。里边的每一个实体对应数据库中一个表,每一个实体的字段对应数据库表中的字段,通过提供的各种装饰器可以随意的通过代码去操作数据库的更改,但这种灵活性也隐藏了巨大的风险。
在我们的项目不断迭代的过程中,一定不可避免的需要增加表 修改字段 增加字段等等操作,如果没有深入研究过TypeORM的使用方式,可能导致数据库中的数据丢失,造成不可挽回的损失。
2 synchronize是个雷
使用TypeORM中的关键配置就是DataSourceOptions
,用来指定连接的数据库及一些操作的选项,常用选项如下:
const options: DataSourceOptions = {
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "123456",
database: "test_juejin",
logging: ["query", "error"],
synchronize: true,
entities: [
User
]
}
其中的synchronize
表示数据库的结构是否和代码保持同步,官方文档说要小心这个选项,不要用在生产环境,除非你的数据是可以丢失的,但是我建议开发的时候也不要开启,下面来看一下在各种代码操作下的开启synchronize
不符合预期的表现。
我们以一个User实体做实验
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}
插入数据:
const dataSource = new DataSource(options)
dataSource.initialize().then(
async (dataSource) => {
const user = new User()
user.name = "Joe Smith"
await dataSource.manager.save(user)
},
(error) => console.log("Cannot connect: ", error),
)
查看数据库, 自动创建了user表和对应代码中的id和name column:
表中内容:
- 1 修改列名 把刚才的name字段改成title
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
}
同步之后的数据库把刚才name换成了title,但是旧的数据已经不存在了。
这到底发生了什么? 正常来说改个字段名称数据应该能保留啊,来看下执行的sql日志。
query: ALTER TABLE `user` CHANGE `name` `title` varchar(255) NOT NULL
query: ALTER TABLE `user` DROP COLUMN `title`
query: ALTER TABLE `user` ADD `title` varchar(255) NOT NULL
好吧 问题就出现这里,如果只是执行第一句的话是没问题的,但是它还把列删除了再添加,所以会导致列中的数据丢失。
- 2 修改列属性 测试一下如果只是改列的属性, 把name字段的长度设置为64。
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({length: 64})
name: string
}
数据库中同样清空了数据:
执行的SQL如下, 进行了删除列然后添加列。
query: ALTER TABLE `user` DROP COLUMN `name`
query: ALTER TABLE `user` ADD `name` varchar(64) NOT NULL
正常我们去改一个表的属性时 只需要修改这一列即可,能够保存原来的数据,而不会先删除列再添加列。
ALTER TABLE `user` modify column name varchar(64) NOT NULL;
以上例子说明 只要改列的名称或者属性都会导致该列的被删除重新创建,所以该列的数据也会丢失,更不要说删除了一列的代码。如果在开发过程中你是开启的watch模式,代码每次改动都会重新执行同步,这样危险就更大了。
3 synchronize原理分析
下面我们通过源码探究一下开启synchronize
内部执行的操作。
DataSource.ts
中initialize()
// if option is set - automatically synchronize a schema
if (this.options.synchronize) await this.synchronize()
调用schemaBuilder.build()
async synchronize(dropBeforeSync: boolean = false): Promise<void> {
......
const schemaBuilder = this.driver.createSchemaBuilder()
await schemaBuilder.build()
}
RdbmsSchemaBuilder.ts
中build
最关键的调用
await this.executeSchemaSyncOperationsInProperOrder()
最后终于找到了最核心的方法.
/**
* Executes schema sync operations in a proper order.
* Order of operations matter here.
*/
protected async executeSchemaSyncOperationsInProperOrder(): Promise<void> {
await this.dropOldViews()
await this.dropOldForeignKeys()
await this.dropOldIndices()
await this.dropOldChecks()
await this.dropOldExclusions()
await this.dropCompositeUniqueConstraints()
// await this.renameTables();
await this.renameColumns()
await this.createNewTables()
await this.dropRemovedColumns()
await this.addNewColumns()
await this.updatePrimaryKeys()
await this.updateExistColumns()
await this.createNewIndices()
await this.createNewChecks()
await this.createNewExclusions()
await this.createCompositeUniqueConstraints()
await this.createForeignKeys()
await this.createViews()
}
我们改列长度会进入updateExistColumns()
, 首先是找到改变的列,产生新旧两个column, 最后继续调用this.queryRunner.changeColumns
async updateExistColumns(){
......
const changedColumns = this.connection.driver.findChangedColumns(table.columns, metadata.columns);
// generate a map of new/old columns
const newAndOldTableColumns = changedColumns.map((changedColumn) => {
const oldTableColumn = table.columns.find((column) => column.name === changedColumn.databaseName);
const newTableColumnOptions = TableUtils_1.TableUtils.createTableColumnOptions(changedColumn, this.connection.driver);
const newTableColumn = new TableColumn_1.TableColumn(newTableColumnOptions);
return {
oldColumn: oldTableColumn,
newColumn: newTableColumn,
};
});
await this.queryRunner.changeColumns(table, newAndOldTableColumns);
}
在最后MysqlQueryRunner.ts
的changeColumn
可以看到 长度不一致就会删除和重新创建列。
if (
(newColumn.isGenerated !== oldColumn.isGenerated &&
newColumn.generationStrategy !== "uuid") ||
oldColumn.type !== newColumn.type ||
oldColumn.length !== newColumn.length ||
(oldColumn.generatedType &&
newColumn.generatedType &&
oldColumn.generatedType !== newColumn.generatedType) ||
(!oldColumn.generatedType &&
newColumn.generatedType === "VIRTUAL") ||
(oldColumn.generatedType === "VIRTUAL" && !newColumn.generatedType)
) {
await this.dropColumn(table, oldColumn)
await this.addColumn(table, newColumn)
}
所以总结来看就是TypeORM
中对于数据库的更新没有做到最优更改。
4 TypeORM的migrations
如果不开启synchronize
, TypeORM中还提供了migrations
来数据库进行更新。
4.1 如何创建migrations
TypeORM提供了cli帮助我们创建migrations
.
- 创建空的迁移文件
npx typeorm migration:create ./path-to-migrations-dir/PostRefactoring
该命令会帮助你在指定位置创建一个迁移文件, 内容如下:
import { MigrationInterface, QueryRunner } from "typeorm"
export class PostRefactoring1657418446962 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
这个命令只是帮助你创建一个空的文件模版,没有太大用途,需要你自己去写迁移的SQL,其中up方法中是本次迁移的SQL语句,down方法中是对应的回滚的SQL语句。
- 自动创建迁移语句
我们可以创建一个单独的
data-source
文件, 导出DataSource
对象
import { DataSource, DataSourceOptions } from "typeorm"
import { User } from "./entity/User"
export default new DataSource({
type: "mysql",
name: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "123456",
database: "test_juejin",
logging: ["query", "error"],
entities: [User],
synchronize: false,
})
然后执行
npx typeorm-ts-node-esm migration:generate ./custom-migrations/update-post-table -d ./data-source.ts
就会自动帮助我们产生更新的SQL语句:
export class updatePostTable1657418884633 implements MigrationInterface {
name = 'updatePostTable1657418884633'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`name\``);
await queryRunner.query(`ALTER TABLE \`user\` ADD \`name\` varchar(20) NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`name\``);
await queryRunner.query(`ALTER TABLE \`user\` ADD \`name\` varchar(255) NOT NULL`);
}
}
其中有一个问题是产生的SQL语句和开启synchronize
中的一样,不是最优的SQL语句。
执行migration
有两种方法,1种是通过cli 另一种是通过API的方式。
首先在DataSource中指定migration相关的两个参数
migrationsTableName: 'custom_migrations_table', // 指定记录migrations的表 如果不设置默认名称是`migrations`.
migrations: [__dirname + "/custom-migrations/*{.js,.ts}"] // 指定migrations文件的位置
使用命令行执行
npx typeorm-ts-node-esm migration:run -d ./data-source.ts
执行之后可以看到数据库中多了一个custom_migrations_table
表,里边记录了历史的迁移文件,这样才能保证不重复执行和回滚。
执行回滚
npx typeorm-ts-node-esm migration:revert -d ./data-source.ts
用API的方式:
await dataSource.runMigrations() // 执行所有未进行过的迁移
await dataSource.undoLastMigration() // 回滚最新的一次迁移
所以用migration
相比于开启synchronize的好处是自己控制更新的,可以自己控制更新的SQL。但是自己生成的SQL仍然不是最优的。
4 先有数据库再用TypeORM
如果我们是先有了数据库,然后才想使用TypeORM,那么可以使用typeorm-model-generator帮助我们生成Entity。
npx typeorm-model-generator -h localhost -d tempdb -u sa -x !Passw0rd -e mssql -o .
5 总结
本文列举了TypeORM中synchronize的坑,从源码中找到了问题的原因。详细讲解了TypeORM中migrations的使用方式,同时推荐了typeorm-model-generator可帮助我们迁移使用TypeORM。
- 如果觉得有用请帮忙点个赞🙏。
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。