今天学 GORM 的外键。
关于 GORM#
GORM
是 Go 语言中我认为做好用的 ORM 库,对开发人员非常友好。拥有以下特性:
- 全功能 ORM
- 关联 (Has One,Has Many,Belongs To,Many To Many,多态,单表继承)
- Create,Save,Update,Delete,Find 中钩子方法
- 支持
Preload
、Joins
的预加载 - 事务,嵌套事务,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
会与另一个模型建立一对一关联,这种模型的每一个实例都 "属于" 另一个模型的实例,也就是一对一关联对照。
例如:一个员工只属于一家公司
type User struct {
gorm.Model
Name string
CompanyID int
Company Company
}
type Company struct {
ID int
Name string
}
这里的 User
和 Company
就是建立了 Belongs To
关系。每个 User
只能分配一个 Company
。User
结构体中,CompanyID
就是用来存储 User
对应的 Company
的 ID
。
GORM 很智能,当我们在结构体中,有一个 Company
类型的字段,并且还有一个 CompanyID
字段,那么 GORM 会自动推断,它会认为,开发者想让 User
与 Company
建立 Belongs To
关系,所以,会自动将 Company
的 ID
指向 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 通常会使用数据表的主键作为外键参考,如同上面 User
和 Company
的例子,就是使用了 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
配置 OnUpdate
和 OnDelete
实现外键约束,例如:
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
,所以外键CompanyID
在User
表中当 User Has One Company 时
:Company
是被拥有方,也可以理解为Company
属于User
,所以外键UserID
就放在Company
中。
基于我目前的理解,我认为 Has One
与 Belongs to
的区别,也就仅仅是语义上的区别,这二者其实都只是 一对一关系
。但是为了更加清晰的表达不同的关系,方便开发者定义不同的模型,所以在语义上,区分了 "包含" 和 "属于"。
重写外键#
都是 一对一关系
,那么 Has One
和 Belongs 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
}
这里就是指定 CreditCard
的 UserName
字段作为外键。
重写引用#
我们可以使用 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
}
这里就是,将 CreditCard
的 UserName
作为外键,并且是将 User
的 Name
字段填充进 UserName
,也就是将 User.Name
与 CreditCard.UserName
做一个绑定。
自引用 Has One#
我们经常会遇到一些场景,需要将同表的数据关联至同表的另一个记录,这就是自引用,当这种关系是一对一时,我们可以称之为:自引用 Has One。
案例如下:
type User struct {
gorm.Model
Name string
ManagerID *uint
Manager *User
}
在这个案例中,就定义了这样的关系,表示用户(员工)和其经理(也是用户)之间的关系。这样的模型定义,可以作为 AFF 拉新分成的数据模型,也就是用户注册时可以写邀请码,邀请码对应了上级,然后通过 自引用 去绑定上级,最终实现分润和上下级绑定。
Has Many#
Has Many
会建立一对多关系,不同于 Has One
,Has 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
关系会在两张表中创建一张连接表,是处理多对多关系的方案。
例如,我们产品中存在 Language
和 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
}
那么,这里就会创建一张 user_languages
的连接表,负责处理 Language
和 User
之间的多对多关系。这里,GORM 会自动创建该连接表。
反向引用#
反向引用 并不是一个专业术语,但是这个词可以很好的描述出 Many To Many
的 双向性。当我们定义了一个多对多关系时,比如 User
和 Language
,我们不仅可以从 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
如果需要重写,那么需要将两个外键的 reference
和 foreignkey
全部重写,当然,也可以选择只重写其中任意一个,根据需求而定。
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
在不同的关系中,表示的含义有细微的不同,我们可以从 直接 和 间接 来理解:
- 直接关系 (Has One, Belongs To, Has Many)
- 外键直接存在于 参与关系 的两个模型中的某一个模型中
- 在这种情况下,
foreignKey
指的就是 持有外键的模型中 的 外键字段名- Has One:指 被 拥有方模型 的外键字段名
比如:Company 的 UserID
- Belongs To:指 拥有方模型 的外键字段名
比如:User 的 CompanyRefer
- Has One:指 被 拥有方模型 的外键字段名
references
指的则是 被引用方 模型中,foreignKey
所引用的字段。比如:User 的 ID
- 间接关系 (Many To Many)
- 外键 存于 独立的连接表 中,由连接表连接两个模型的关联关系
- 这里的
tag
更多是用来 配置连接表如何与两个模型关联 的 - 对于
Many To Many
关系,我认为 拥有方 与 被拥有方 的关系是很模糊的,所以这里不使用这种词代指两个模型,而是采用 A 模型 和 B 模型 的方法。foreignKey
指 A 模型 中被连接表外键引用的字段名joinForeignKey
指的是 连接表 中指向 A 模型 的外键名references
指的则是 B 模型 中被连接表外键引用的字段名joinReferences
指的是 连接表 中指向 B 模型 的外键名
带着这些概念,再去看上面的案例,其实就很好懂了,A 模型 可以代指 User
,B 模型 则代指 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{})
总结#
没啥总结,其实是想写点东西,但是技术太强大了太废物了。
下一期可能会出关联模式