第一次使用Typeorm的挖坑总结

6,070 阅读10分钟

最近一个公司官网需要做后台管理,自告奋勇伸出手接下这活。我本来计划技术栈是 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 插入最终都会 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

也许你还会想到其他方案,可以和我交流。

写在最后

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


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

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。