开发环境:Nest.js + TypeORM + MySQL

这篇文章仅是由我本人的开发过程中总结出来的一些规律,希望能够给有需要的同学一些帮助。

我们都知道,在传统的关系型数据库当中,如果某个实体和某个实体之间存在一定程度上的相关性,那么必须要在对应的数据库中去配置好这一层关系,否则无法保证数据库的完整性。

现阶段总共的级联关系包括:

  • 一对一
  • 一对多
  • 多对多

# 实验:nest 结合 typeorm 进行级联关系处理

# 前置任务:实体准备

在这里,一般的 typeorm 结合 nest 的配置方法暂且不详细展开。默认大家已经在 nest 中配置好了 typeorm 和 mysql 的连接。

连接好之后,我们可以先准备几个实体以供实验。我在这里准备采用如下的实体列表:

  • 用户 user
  • 头像 avatar
  • 文章 article
  • 标签 label

它们之间有如下的几种级联关系:

  • 用户和头像:一对一(One-to-One)关系,每个用户有一个唯一的头像。
  • 用户和文章:一对多(One-to-Many)关系,一个用户可以有多篇文章。
  • 文章和标签:多对多(Many-to-Many)关系,一篇文章可以有多个标签,一个标签可以关联多篇文章。

首先,新建 user 模块和 avatar 模块:

nest g res user
nest g res avatar
nest g res article
nest g res label

此处都选择 RESTful 模式,并且生成 CRUD。此时你会在目录中看到这四个模块。

我们进入 entities 目录,开始使用 typeorm 对四个实体进行编写。

user.entity.ts

// user.entity.ts
import { Column, Entity, OneToOne, PrimaryGeneratedColumn } from "typeorm";
@Entity({
  name: "users",
})
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  username: string;
}

avatar.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, OneToOne } from "typeorm";
@Entity({
  name: "avatars",
})
export class Avatar {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  url: string;
}

article.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity({
  name: "articles",
})
export class Article {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "text",
  })
  content: string;
}

label.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity({
  name: "labels",
})
export class Label {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  value: string;
}

可以看到,我们已经分别定义了四个实体,彼此有各自的字段属性。我们启动 nest 应用时,会根据实体生成对应的表。但是此刻它们还没有 联系,我们需要使用一些特殊的装饰器进行处理。

# 前置知识:处理级联关系的装饰器

# 装饰器汇总

在 typeorm 中,用于处理级联关系的装饰器一般有如下几个:

  1. @OneToOne :定义一对一的关系,当一个实体实例与另一个实体实例有唯一对应关系时使用。
  2. @OneToMany :定义一对多的实体关系,当一个实体实例可以关联多个另一个实体实例时使用。通常和 @ManyToOne 装饰器一起使用。
  3. @ManyToOne :定义多对一的实体关系,当多个实体实例可以关联到一个单独的实体实例时使用。通常和 @OneToMany 装饰器一起使用。
  4. @ManyToMany :定义多对多的实体关系,当多个实体实例可以关联到多个另一个实体实例时使用。
  5. @JoinTable :定义多对多关系中的连接表,在多对多关系中使用,用于指定关系连接表及其列。使用时,仅需要在多对多联系的任意一方加入即可。
  6. @JoinColumn :定义 外键 和连接列,在 @OneToOne@ManyToOne 关系中使用,用于指定连接列。一般来说,加在被 @ManyToOne 修饰的那个字段。

其中,1~4 装饰器用来从 实体层面 定义级联关系;5~6 装饰器用来处理 数据库层面 的级联关系以维护数据库完整性。

# 参数说明

根据装饰器的不同,它们接受不同的配置参数。

# 1~4

对于 1~4 装饰器,它们通常接受三个参数:

  • () => TargetEntity
  • (sourceEntity) => sourceEntity.property
  • options

第一个参数,是一个返回目标实体类型的函数。这个参数通常是一个返回目标实体类型的箭头函数,TypeORM 会使用这个函数来解析目标实体类型。

第二个参数,是一个返回目标实体中与当前实体相关联的属性的函数。这个函数用于定义目标实体和源实体之间的关系。

第三个参数,是一个可选的配置对象,用于指定关系的额外选项。例如,你可以在这里定义是否自动加载相关实体,是否级联删除等。我们用以配置级联的详细内容,就是在这个配置项中指定的。

对于第三个参数的详细配置选项,有非常非常多。我将按照参数的作用类型进行分类后在下一个模块中作详细说明。

# @JoinTable

接受一个可选的配置对象,该对象包含以下属性:

  • name

    类型: string

    描述:指定连接表的名称。如果不指定,TypeORM 会自动生成一个连接表名称。

  • joinColumns

    类型: JoinColumnOptions[]

    描述:定义当前实体的连接列。 name 是连接表中的列名, referencedColumnName 是当前实体中的列名(通常是主键)。

  • inverseJoinColumns

    类型: JoinColumnOptions[]

    描述:定义关系实体的连接列。 name 是连接表中的列名, referencedColumnName 是关系实体中的列名(通常是主键)。

# @JoinColumn

接受一个或两个配置对象。

接受单个配置对象时:

  • name

    类型: string

    描述:指定外键列的名称。如果不指定,TypeORM 会使用默认生成的列名。

  • referencedColumnName

    类型: string

    描述:指定目标实体中的列名。通常是目标实体的主键。

接受两个配置对象时,第一个对象用于定义当前实体中的连接列,第二个对象用于定义目标实体中的连接列。比如:

@JoinColumn([
  { name: 'localColumn1', referencedColumnName: 'referencedColumn1' },
  { name: 'localColumn2', referencedColumnName: 'referencedColumn2' }
])

# 对 options 配置项的特殊说明

一般来说,配置具体的级联操作全是通过这一个对象配置完成的,因此它提供的配置项也有非常非常多。

# 关系的基本配置

  • cascade: 指定是否级联操作。可以是一个布尔值或者一个数组,用于指定级联的操作类型:

    • true 或者 ['insert', 'update', 'remove', 'soft-remove', 'recover'] :进行所有类型的级联操作。
    • false :不进行任何级联操作。
    • 数组:例如 ['insert', 'update'] 表示仅对插入和更新操作进行级联。
  • nullable: 指定关系是否可以为空。默认为 true ,即可以为空。

  • onDelete: 指定当关系删除时的操作。有以下几种选择:

    • 'CASCADE' :删除该实体时,会级联删除相关的实体。
    • 'SET NULL' :删除该实体时,会将外键设为 NULL
    • 'RESTRICT' :如果有关联存在,则不能删除该实体。
    • 'NO ACTION' :默认行为,通常等同于 RESTRICT
    • 'SET DEFAULT' :删除该实体时,会将外键设为默认值。
  • onUpdate: 指定当关系更新时的操作,选项同 onDelete

在这里,详细说明一下 cascade: ['remove']onDelete: 'CASCADE' 的主要区别。

cascade 用于定义在某些操作(如插入、更新、删除等)时,是否对相关的关联实体进行级联操作。

  • 作用范围: cascade 主要影响在 应用程序层面 的操作,也就是 你实际写代码层面 的操作。例如在用实体对应的 Repository 对实体进行保存(save)、删除(remove)等操作时,是否对关联的实体进行同样的操作。

  • 可用选项: cascade 接受一个布尔值或者一个字符串数组,指定对哪些操作进行级联处理。

    • true : 等同于 ['insert', 'update', 'remove', 'soft-remove', 'recover'] ,表示对所有支持的操作都进行级联处理。
    • false : 不进行任何级联操作。
    • 数组:你可以指定具体的操作类型,例如 ['insert', 'update']
  • 操作影响:

    • insert : 当插入主实体时,会插入关联的实体。
    • update : 当更新主实体时,会更新关联的实体。
    • remove : 当删除主实体时,会删除关联的实体。
    • soft-remove : 当软删除主实体时,会软删除关联的实体。
    • recover : 当恢复主实体时,会恢复关联的实体。
  • 使用场景:适用于需要在操作主实体的同时对其关联实体进行同步操作的场景。例如,保存一个包含关联实体的主实体时,希望自动保存这些关联实体。

  • 代码示例:

    @ManyToOne(() => User, user => user.posts, {
      cascade: ['insert', 'update']
    })
    user: User;

    在这个例子中,当你插入或更新 Post 实体时,相关的 User 实体也会被插入或更新。

onDelete 配置项用于定义在 数据库层面 上,当主实体被删除时,如何处理与其相关的外键约束。

  • 作用范围: onDelete 主要影响数据库中的外键约束行为,当你在数据库中删除主实体时,如何处理与之关联的外键记录。

  • 可用选项: onDelete 接受字符串值,指定在删除主实体时对关联的外键记录进行的操作。

    • 'CASCADE' : 当删除主实体时,自动删除所有引用该实体的外键记录(关联实体)。
    • 'SET NULL' : 当删除主实体时,将外键记录的值设置为 NULL
    • 'RESTRICT' : 当存在引用该实体的外键记录时,禁止删除主实体。
    • 'NO ACTION' : 默认行为,通常等同于 RESTRICT ,即禁止删除。
    • 'SET DEFAULT' : 当删除主实体时,将外键记录的值设置为默认值。
  • 操作影响:

    • 'CASCADE' : 级联删除所有引用该实体的关联实体。这是一种硬删除,会从数据库中物理删除记录。
    • 'SET NULL' : 外键被设置为 NULL ,这样不会删除关联的实体,只是解除关联。
    • 'RESTRICT''NO ACTION' : 禁止删除有依赖关系的实体,保证数据完整性。
  • 使用场景:适用于需要确保数据库外键关系的一致性时使用。例如,在删除一个用户时,需要同时删除所有与之关联的订单记录。

  • 代码示例:

    @ManyToOne(() => User, user => user.posts, {
      onDelete: 'CASCADE'
    })
    user: User;

    在这个例子中,当你删除 User 实体时,所有关联的 Post 实体也会被删除。

至此,我们可以总结一下:

  • 应用层次不同:
    • cascade 主要用于应用程序层面的操作控制,如插入、更新和删除操作时的关联实体处理。
    • onDelete 主要用于数据库层面的外键约束控制,定义删除主实体时对外键记录的处理方式。
  • 操作对象不同:
    • cascade 操作对象是与主实体相关联的其他实体,应用在 TypeORM 操作时。
    • onDelete 操作对象是数据库表中的外键记录,影响的是数据库存储的行为。
  • 功能侧重点不同:
    • cascade 更侧重于对关联实体的同步操作,适用于复杂的对象关系操作场景。
    • onDelete 更侧重于数据完整性和一致性,适用于防止孤立数据的场景。

# 性能优化和缓存

  • eager: 指定是否在查询实体时自动加载关联实体。默认为 false

    • true :会在查询时自动加载关联的实体。
    • false :不会自动加载,需要手动指定。
  • lazy: 指定是否启用惰性加载。默认为 false

    • true :当访问关联属性时,才会执行查询并加载实体。
  • persistence: 指定是否允许持久化。默认为 true

# 加载策略

  • loadRelationIds: 指定是否仅加载关联的外键而不是整个实体。

    • true :只加载外键 ID。
    • false :加载整个关联实体。
  • relationLoadStrategy: 指定加载策略。

    • 'join' :使用连接查询加载关联实体。
    • 'query' :使用单独的查询来加载关联实体。

# 其他配置

  • deferrable: 指定是否推迟外键约束检查。

    • 'INITIALLY IMMEDIATE' :立即检查外键约束。
    • 'INITIALLY DEFERRED' :事务提交时检查外键约束。
  • primary: 指定是否为主键。

    • true :此关系会成为主键的一部分。
    • false :不是主键。
  • orphanedRowAction: 指定当移除关系时孤儿行的处理方式。

    • 'nullify' :将孤儿行的外键设为 NULL
    • 'delete' :删除孤儿行。
  • index: 指定是否为该列创建索引。

    • true :创建索引。
    • false :不创建索引。

# 实现一对一的关系

根据上述的前置知识,我们了解到了如何在 nest 当中使用 typeorm 编写实体与实体之间的级联关系。

对于一对一,我们可以使用 @OneToOne@JoinColumn 来对 user 和 avatar 实体进行修饰。

编写后的实体如下:

// user.entity.ts
@Entity({
  name: "users",
})
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  username: string;
  @OneToOne(() => Avatar, (avatar) => avatar.user, {
    onDelete: "CASCADE",
  })
  avatar: Avatar;
}
// avatar.entity.ts
@Entity({
  name: "avatars",
})
export class Avatar {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  small: string;
  @Column({
    type: "varchar",
    length: 20,
  })
  medium: string;
  @Column({
    type: "varchar",
    length: 20,
  })
  large: string;
  @OneToOne(() => User, (user) => user.avatar)
  @JoinColumn({ name: "user_id" })
  user: User;
}

在这里,我们建立了一对一的实体关系,并且将外键列指定在了 avatars 表中,对应的字段名为 user_id 。级联关系指定在了 user 实体中,这意味着在删除这个用户的时候,会把对应的头像也给删除。

实际上,一对一的关系可以直接合成一张表,因此不太常用。

# 实现一对多的关系

一对多的关系实际上和一对一的实现差不多,方式类似,只是换了个装饰器。

在这里,我们所需要实现的关系为:一个用户可以有多篇文章。

使用装饰器,更新实体如下:

// user.entity.ts
@Entity({
  name: "users",
})
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  username: string;
  @OneToOne(() => Avatar, (avatar) => avatar.user, {
    onDelete: "CASCADE",
  })
  avatar: Avatar;
  @OneToMany(() => Article, (article) => article.user)
  articles: Article[];
}
// article.entity.ts
@Entity({
  name: "articles",
})
export class Article {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "text",
  })
  content: string;
  @ManyToOne(() => User, (user) => user.articles, {
    onDelete: "CASCADE",
  })
  @JoinColumn({ name: "user_id" })
  user: User;
}

@OneToMany 一般加在一对多的” 一 “的一方,并且通常无需进行多余的配置。如果有需要,可能需要配置一下 options。

@ManyToOne 一般加载一对多的” 多 “的一方,并且 一般在这个属性上加上外键列 @JoinColumn ,且指定数据库层面的级联关系。我在这里指定了数据库层面的级联删除。

# 实现多对多的关系

多对多的关系和其他两种关系的区别在于,这个关系需要生成一个新的邻接表来保存级联关系。

我们可以使用 @ManyToMany@JoinTable 两个装饰器来实现。

在上述实体中,一篇文章可以有多个标签,一个标签可以关联多篇文章。

我们可以修改实体如下:

// label.entity.ts
@Entity({
  name: "labels",
})
export class Label {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "varchar",
    length: 20,
  })
  value: string;
  @ManyToMany(() => Article, (article) => article.labels)
  @JoinTable({ name: "article_label" })
  articles: Article[];
}
// article.entity.ts
@Entity({
  name: "articles",
})
export class Article {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({
    type: "text",
  })
  content: string;
  @ManyToOne(() => User, (user) => user.articles, {
    onDelete: "CASCADE",
  })
  @JoinColumn({ name: "user_id" })
  user: User;
  @ManyToMany(() => Label, (label) => label.articles)
  labels: Label[];
}

使用方式实际上和 @OneToOne 类似,把 @JoinColumn 换成 @JoinTable 以实现邻接表的创建。当然,这个装饰器也是双方都可以进行插入。相对应的级联关系,也是按照类似的方法进行配置即可。

# 启动 nest 服务

当然,实体编写完成后,也不要忘记去 app.module.ts 中对 typeorm 配置进行一下更新。

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "mysql",
      host: "localhost",
      port: 3306,
      username: "root",
      password: "xxx",
      database: "typeorm-practice",
      synchronize: true,
      logging: true,
      entities: [User, Avatar, Label, Article], // 更新实体的引入
      poolSize: 10,
      connectorPackage: "mysql2",
      extra: {
        authPlugin: "sha256_password",
      },
    }),
    AvatarModule,
    UserModule,
    ArticleModule,
    LabelModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

配置完成后,我们可以尝试启动一下 nest 服务,观察数据库是否按照我们的意愿正常生成。

image-20240623170001135

可以看到,数据库表都已经正常的生成完毕,并且绑定了对应的外键与实体关系。