jiayisheji/blog

第一次使用Typeorm的挖坑总结

jiayisheji opened this issue · 0 comments

最近一个公司官网需要做后台管理,自告奋勇伸出手接下这活。我本来计划技术栈是 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 对应叫 EntityEntity 里面字段列和数据库里面的是一一对应的。

换句话来说,在数据库里面建表,要么手动建,设计表结构,另外一种就是 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})

满心欢喜插入了数据库,发现数据库里面的数据 id0

一开始不懂为什么,按道理我设置自增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 语句:

  1. 显示所有数据表
show databases;
  1. 切换指定数据表
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 一个默认值 DEFAULTMysql 就会给它默认一个 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 插入最终都会 https://github.com/typeorm/typeorm/blob/master/src/query-builder/ReturningResultsEntityUpdator.ts 里的 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
            })

通过 https://github.com/typeorm/typeorm/blob/master/src/metadata/EntityMetadata.ts 里的 EntityMetadata.getValueMap() 静态方法获取。

在通过 https://github.com/typeorm/typeorm/blob/master/src/metadata/ColumnMetadata.ts 里的 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 最终就解决报错问题。

写在最后

无论使用什么技术都没有一帆风顺的,总是有无尽的坑需要填,各方面原因凑在一起就引起未知的坑,我们需要掌握排坑技巧,不断提升解决问题的能力。

今天就到这里吧,伙计们,玩得开心,祝你好运。