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

今天学 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 会与另一个模型建立一对一关联，这种模型的每一个实例都 "属于" 另一个模型的实例，也就是一对一关联对照。

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

Copy
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 来指定外键名，例如：

Copy
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 来指定，例如：

Copy
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 的关系，所以需要手动指定，例如：

Copy
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 实现外键约束，例如：

Copy
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 表示一个模型的每个实例都 "拥有" 另一个模型的一个实例。

可以这样理解：

Copy
// 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 来指定另一个字段来作为外键：

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

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

这里就是指定 CreditCard 的 UserName 字段作为外键。

重写引用

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

Copy
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。

案例如下：

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

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

Has Many

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

Copy
// 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 表示一个记录可以关联到多个同类型的其他记录。

例如：

Copy
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 所属于。

Copy
// 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 使用该语言。也就是可以 "反过来查"，从 "另一端" 加载数据的能力，就被称为反向引用。

例如：

Copy
// 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，并且都定义了 关联关系。

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

Copy
// 检索 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 这个连接表中，就会有下面两个外键：

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

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

Copy
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。

所以最终的表会是：

Copy
// 会创建连接表：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

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 关系指的是一个模型与自身建立多对多关系。这个功能使用最多的地方是好友功能，一个用户可以有多个好友，并且可以是很多个人的好友。

Copy
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 函数设置，例如：

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

Copy
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{})

总结

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

下一期可能会出关联模式