FanXing Blog

FanXing Blog

一个热爱编程的高中生,正在努力成为一位优秀的后端工程师~

Day3. Go语言精进之路:GORM 外键

今天学 GORM 的外键。


关于 GORM#

GORM 是 Go 语言中我认为做好用的 ORM 库,对开发人员非常友好。拥有以下特性:

  • 全功能 ORM
  • 关联 (Has One,Has Many,Belongs To,Many To Many,多态,单表继承)
  • Create,Save,Update,Delete,Find 中钩子方法
  • 支持 PreloadJoins 的预加载
  • 事务,嵌套事务,Save Point,Rollback To Saved Point
  • Context、预编译模式、DryRun 模式
  • 批量插入,FindInBatches,Find/Create with Map,使用 SQL 表达式、Context Valuer 进行 CRUD
  • SQL 构建器,Upsert,数据库锁,Optimizer/Index/Comment Hint,命名参数,子查询
  • 复合主键,索引,约束
  • Auto Migration
  • 自定义 Logger
  • 灵活的可扩展插件 API:Database Resolver(多数据库,读写分离)、Prometheus…
  • 每个特性都经过了测试的重重考验
  • 开发者友好

外键#

在关系型数据库中,外键 (是一个) 或是一组字段,用于建立两个表数据之间的连接。外键通常是指向另一个表的主键,它的主要作用是:

  • 关联性(Relationship):将一个表中的记录与另一个表中的记录关联起来。
  • 数据完整性(Data Integrity):确保引用的数据是有效的。例如:不能创建一个用户不存在的订单,也就是订单对应的用户根本不存在与数据库

GORM 遵循 ” 约定优于配置 “ 的原则,能够自动判断出对应的外键关系。

注意:部分数据库不会为外键自动创建索引

Belongs To#

Belongs To 会与另一个模型建立一对一关联,这种模型的每一个实例都 "属于" 另一个模型的实例,也就是一对一关联对照。

image

例如:一个员工只属于一家公司

type User struct {
	gorm.Model
	Name      string
	CompanyID int
	Company   Company
}

type Company struct {
	ID   int
	Name string
}

这里的 UserCompany 就是建立了 Belongs To 关系。每个 User 只能分配一个 CompanyUser 结构体中,CompanyID 就是用来存储 User 对应的 CompanyID

image

GORM 很智能,当我们在结构体中,有一个 Company 类型的字段,并且还有一个 CompanyID 字段,那么 GORM 会自动推断,它会认为,开发者想让 UserCompany 建立 Belongs To 关系,所以,会自动将 CompanyID 指向 User.CompanyID 字段,也就是建立外键关系,这是 GORM 的约定。

CompanyID 字段必须在 User 结构体中,GORM 才能识别并推断,没有这个字段,GORM 就无法去将记录链接到 Company 表中的记录,字段名也是一种约定,必须为 ModelName + ID 这样才可以。

为什么还会有一个类型为 Company 的字段呢?因为在查询 User 的数据时,可以使用 GORM 的 预加载 功能同时将对应的 Company 信息加载到 User 结构体中。GORM 会通过 CompanyID 这个字段,去 Company 表中查询对应的数据。

重写外键#

要定义一个 Belongs to 关系的外键,默认情况下,外键字段名是:拥有者的类型名称+表的主键的字段名字,也就是 Company 是类型名,ID 是主键字段名。

但是我们可以使用一些 tag 来指定外键名,例如:

type User struct {
  gorm.Model
  Name         string
  CompanyRefer int
  Company      Company `gorm:"foreignKey:CompanyRefer"`
  // 使用 CompanyRefer 作为外键
}

type Company struct {
  ID   int
  Name string
}

重写引用#

Belongs to 中,GORM 通常会使用数据表的主键作为外键参考,如同上面 UserCompany 的例子,就是使用了 Company 的主键 ID 作为外键。

同样的,也可以使用 tag 来指定,例如:

type User struct {
  gorm.Model
  Name      string
  CompanyID string
  Company   Company `gorm:"references:Code"` // 使用 Code 作为引用
  // 这样 GORM 就会将 Company.Code 自动填充至 CompanyID 作为外键
}

type Company struct {
  ID   int
  Code string
  Name string
}

但是如果外键名恰好在拥有者类型中存在,GORM 通常会错误的误认为是 Has One 的关系,所以需要手动指定,例如:

type User struct {
  gorm.Model
  Name      string
  CompanyID int
  Company   Company `gorm:"references:CompanyID"` // use Company.CompanyID as references
}

type Company struct {
  CompanyID   int
  Code        string
  Name        string
}

外键约束#

我们还可以通过 constraint 配置 OnUpdateOnDelete 实现外键约束,例如:

type User struct {
  gorm.Model
  Name      string
  CompanyID int
  Company   Company `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}

type Company struct {
  ID   int
  Name string
}

Has One#

Has One 与另一个模型建立一对一关系,但是与 Belongs to 不太一样。Has One 表示一个模型的每个实例都 "拥有" 另一个模型的一个实例。

可以这样理解:

// Belongs to
// User 属于 Company
// 这里就是 User 属于 Company,也就是用户属于这家公司,是这家公司的员工的意思
type User struct {
    gorm.Model
    Name      string
    CompanyID int     // 外键:存储所属 Company 的 ID
    Company   Company // Belongs To 关系声明
}

type Company struct {
    ID   int      // Company 的主键
    Name string
}


// Has One
// User 拥有一条 Company 记录
// 这里就是,User 拥有一个 Company,也就是这个用户创办了一个公司
type User struct {
    gorm.Model
    Name    string
    Company Company // Has One 关系声明
}

type Company struct {
    ID     int      // Company 的主键
    Name   string
    UserID uint     // 外键:存储所属 User 的 ID
}

核心规则:外键总是放在 “引用” 或 “从属” 的那一方。

  • 当 User Belongs to Company 时User 是从属方,表达的含义是 User 属于 Company,所以外键 CompanyIDUser 表中
  • 当 User Has One Company 时Company 是被拥有方,也可以理解为 Company 属于 User,所以外键 UserID 就放在 Company 中。

基于我目前的理解,我认为 Has OneBelongs to 的区别,也就仅仅是语义上的区别,这二者其实都只是 一对一关系。但是为了更加清晰的表达不同的关系,方便开发者定义不同的模型,所以在语义上,区分了 "包含" 和 "属于"。

重写外键#

都是 一对一关系,那么 Has OneBelongs to 一样,都必须存在外键字段。默认是由 Has one 的模型类型 + 主键 生成,对于 Company 这个例子,就是 UserID 了。

但是我们也可以使用 tag 来指定另一个字段来作为外键:

type User struct {
  gorm.Model
  CreditCard CreditCard `gorm:"foreignKey:UserName"` // 使用 UserName 作为外键
}

type CreditCard struct {
  gorm.Model
  Number   string
  UserName string
}

这里就是指定 CreditCardUserName 字段作为外键。

重写引用#

我们可以使用 foreignKey 来指定外键,也就是对应模型中作为外键的字段。当然,也可以指定当前模型中的引用,也就是填充到外键模型的中的字段:

type User struct {
  gorm.Model
  Name       string     `gorm:"index"`
  CreditCard CreditCard `gorm:"foreignKey:UserName;references:Name"`
}

type CreditCard struct {
  gorm.Model
  Number   string
  UserName string
}

这里就是,将 CreditCardUserName 作为外键,并且是将 UserName 字段填充进 UserName,也就是将 User.NameCreditCard.UserName 做一个绑定。

自引用 Has One#

我们经常会遇到一些场景,需要将同表的数据关联至同表的另一个记录,这就是自引用,当这种关系是一对一时,我们可以称之为:自引用 Has One

案例如下:

type User struct {
  gorm.Model
  Name      string
  ManagerID *uint
  Manager   *User
}

在这个案例中,就定义了这样的关系,表示用户(员工)和其经理(也是用户)之间的关系。这样的模型定义,可以作为 AFF 拉新分成的数据模型,也就是用户注册时可以写邀请码,邀请码对应了上级,然后通过 自引用 去绑定上级,最终实现分润和上下级绑定。

Has Many#

Has Many 会建立一对多关系,不同于 Has OneHas Many 指的是拥有多个,也就是一个拥有多个。比如:

// User 有多张 CreditCard,UserID 是外键
type User struct {
  gorm.Model
  CreditCards []CreditCard
}

type CreditCard struct {
  gorm.Model
  Number string
  UserID uint
}

这里就是一个经典的 Has Many 关系,一个 User 可以有多个 CreditCard

重写外键#

还是使用 foreignKey,这里不再演示。

重写引用#

使用 references,不再演示。

自引用 Has Many#

自引用 Has Many自引用 Has One 类似,也就是在同一个数据表中的不同记录之间创建关联。不同的地方是,Has Many 表示一个记录可以关联到多个同类型的其他记录。

例如:

type User struct {
  gorm.Model
  Name      string
  ManagerID *uint
  Team      []User `gorm:"foreignkey:ManagerID"`
}

这个例子就是,一个用户管理层 (User) 可以有多个下属 (User)。

自引用 Has One 的区别就是,Has One 更加侧重于查找用户的上级,而 Has Many 则更侧重与查找用户的下级。

Many To Many#

Many To Many 关系会在两张表中创建一张连接表,是处理多对多关系的方案。

例如,我们产品中存在 LanguageUser,可以说一个 User 包含多种 Language,也可以说一个 Language 被多个 User 所属于。

// User 拥有并属于多种 language,`user_languages` 是连接表
type User struct {
  gorm.Model
  Languages []Language `gorm:"many2many:user_languages;"`
}

type Language struct {
  gorm.Model
  Name string
}

那么,这里就会创建一张 user_languages 的连接表,负责处理 LanguageUser 之间的多对多关系。这里,GORM 会自动创建该连接表。

反向引用#

反向引用 并不是一个专业术语,但是这个词可以很好的描述出 Many To Many双向性。当我们定义了一个多对多关系时,比如 UserLanguage ,我们不仅可以从 User 去查询这个 User 所使用的 Language,也可以从 Language 查询有哪些 User 使用该语言。也就是可以 "反过来查",从 "另一端" 加载数据的能力,就被称为反向引用。

例如:

// User 拥有并属于多种 language,`user_languages` 是连接表
type User struct {
  gorm.Model
  Languages []*Language `gorm:"many2many:user_languages;"`
}

type Language struct {
  gorm.Model
  Name string
  Users []*User `gorm:"many2many:user_languages;"`
}

这就是一个很经典的反向引用案例,二者指向了同一个连接表 user_languages,并且都定义了 关联关系

我们可以很方便的使用 预加载 去加载关联数据,例如:

// 检索 User 列表并预加载 Language(正向)
func GetAllUsers(db *gorm.DB) ([]User, error) {
    var users []User
    err := db.Model(&User{}).Preload("Languages").Find(&users).Error
    return users, err
}

// 检索 Language 列表并预加载 User(反向)
func GetAllLanguages(db *gorm.DB) ([]Language, error) {
    var languages []Language
    err := db.Model(&Language{}).Preload("Users").Find(&languages).Error
    return languages, err
}

反向引用 这个功能也恰好说明了,GORM 中的 Many To Many 关系,是对称的。通过在两个模型之间使用 Many To Many 并指向同一张连接表,GORM 就会具备从任何一方查询关联数据的能力。

重写外键#

对于 Many To Many 的重写外键,与一般的重写外键不一样,因为该关系需要一张连接表,而连接表中至少存在两个外键,分别是两个模型的外键。比如上面的例子,那么 user_languages 这个连接表中,就会有下面两个外键:

// 连接表:user_languages
//   foreign key: user_id, reference: users.id
//   foreign key: language_id, reference: languages.id

如果需要重写,那么需要将两个外键的 referenceforeignkey 全部重写,当然,也可以选择只重写其中任意一个,根据需求而定。

type User struct {
    gorm.Model
    Profiles []Profile `gorm:"many2many:user_profiles;foreignKey:Refer;joinForeignKey:UserReferID;References:UserRefer;joinReferences:ProfileRefer"`
    Refer    uint      `gorm:"index:,unique"`
}

type Profile struct {
    gorm.Model
    Name      string
    UserRefer uint `gorm:"index:,unique"`
}

这里使用 many2many 指定 ManyToMany 关系和连接表名称。然后使用 foreignKey 来指定来指定 User 模型中与 user_profiles 表关联的字段为 Refer,然后使用 JoinForeignKey 来指定外键名称为 UserReferID。使用 References 来指定 Profile 模型中与 user_profiles 表关联的字段为 UserRefer,然后使用 JoinReferences 来指定外键名称为 ProfilesRefer

所以最终的表会是:

// 会创建连接表:user_profiles
//   foreign key: user_refer_id, reference: users.refer
//   foreign key: profile_refer, reference: profiles.user_refer

这里涉及到的 tag 比较乱,所以我会详细解释一下。

在 GORM 中,提供了一些 tag 来支持重写外键的功能,但是这些 tag 在不同的关系中,表示的含义有细微的不同,我们可以从 直接间接 来理解:

  1. 直接关系 (Has One, Belongs To, Has Many)
    • 外键直接存在于 参与关系 的两个模型中的某一个模型中
    • 在这种情况下,foreignKey 指的就是 持有外键的模型中外键字段名
      • Has One:指 被 拥有方模型 的外键字段名
        • 比如:Company 的 UserID
      • Belongs To:指 拥有方模型 的外键字段名
        • 比如:User 的 CompanyRefer
    • references 指的则是 被引用方 模型中,foreignKey 所引用的字段。
      • 比如:User 的 ID
  2. 间接关系 (Many To Many)
    • 外键 存于 独立的连接表 中,由连接表连接两个模型的关联关系
    • 这里的 tag 更多是用来 配置连接表如何与两个模型关联
    • 对于 Many To Many 关系,我认为 拥有方被拥有方 的关系是很模糊的,所以这里不使用这种词代指两个模型,而是采用 A 模型B 模型 的方法。
      • foreignKeyA 模型 中被连接表外键引用的字段名
      • joinForeignKey 指的是 连接表 中指向 A 模型 的外键名
      • references 指的则是 B 模型 中被连接表外键引用的字段名
      • joinReferences 指的是 连接表 中指向 B 模型 的外键名

带着这些概念,再去看上面的案例,其实就很好懂了,A 模型 可以代指 UserB 模型 则代指 Profile

自引用 Many2Many#

Many To Many 关系指的是一个模型与自身建立多对多关系。这个功能使用最多的地方是好友功能,一个用户可以有多个好友,并且可以是很多个人的好友。

type User struct {
  gorm.Model
    Friends []*User `gorm:"many2many:user_friends"`
}

// 会创建连接表:user_friends
//   foreign key: user_id, reference: users.id
//   foreign key: friend_id, reference: users.id

自定义连接表#

连接表 可以是一个功能齐全的模型,比如支持:软删除钩子函数 等功能,并且可以具有更多的字段,我们可以通过 SetupJoinTable 函数设置,例如:

自定义连接表要求外键是复合主键或复合唯一索引

type Person struct {
  ID        int
  Name      string
  Addresses []Address `gorm:"many2many:person_addresses;"`
}

type Address struct {
  ID   uint
  Name string
}

type PersonAddress struct {
  PersonID  int `gorm:"primaryKey"`
  AddressID int `gorm:"primaryKey"`
  CreatedAt time.Time
  DeletedAt gorm.DeletedAt
}

func (PersonAddress) BeforeCreate(db *gorm.DB) error {
  // ...
}

// 修改 Person 的 Addresses 字段的连接表为 PersonAddress
// PersonAddress 必须定义好所需的外键,否则会报错
err := db.SetupJoinTable(&Person{}, "Addresses", &PersonAddress{})

总结#

没啥总结,其实是想写点东西,但是技术太强大了太废物了。

下一期可能会出关联模式

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。