Appearance
03-关联关系:一对一、一对多、多对多
现实世界的业务对象之间存在着丰富的关联关系:用户与用户详情一对一、用户与文章一对多、文章与标签多对多。GORM 通过结构体嵌套和标签提供了完整的关联关系支持,并通过 Preload、Association API 等机制简化关联操作。本篇将系统讲解 GORM 的四种关联关系及其使用方法。
一对一关系:Has One
Has One 表示一个模型拥有另一个模型,外键定义在被拥有的模型上。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// User 用户:拥有一个 Profile
type User struct {
ID uint
Name string
Profile Profile // has one
}
// Profile 用户详情:包含 UserID 外键
type Profile struct {
ID uint
UserID uint // 外键,默认为 <拥有者模型名>+ID
Bio string
Avatar string
}
func main() {
db, err := gorm.Open(sqlite.Open("hasone.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Profile{})
// 创建用户并关联 Profile(级联创建)
user := User{
Name: "Tom",
Profile: Profile{
Bio: "Go 开发者",
Avatar: "tom.png",
},
}
db.Create(&user)
// 预加载查询
var found User
db.Preload("Profile").First(&found, user.ID)
fmt.Printf("用户: %s, 简介: %s, 头像: %s\n",
found.Name, found.Profile.Bio, found.Profile.Avatar)
}一对多关系:Has Many
Has Many 表示一个模型拥有多个其他模型。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// User 一个用户有多篇文章
type User struct {
ID uint
Name string
Posts []Post // has many
}
// Post 文章:包含 UserID 外键
type Post struct {
ID uint
UserID uint // 外键
Title string
}
func main() {
db, err := gorm.Open(sqlite.Open("hasmany.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Post{})
// 创建用户并关联多篇文章
user := User{
Name: "Tom",
Posts: []Post{
{Title: "文章1"},
{Title: "文章2"},
{Title: "文章3"},
},
}
db.Create(&user)
// 预加载查询
var found User
db.Preload("Posts").First(&found, user.ID)
fmt.Printf("用户 %s 有 %d 篇文章:\n", found.Name, len(found.Posts))
for _, p := range found.Posts {
fmt.Printf(" - %s\n", p.Title)
}
}属于关系:Belongs To
Belongs To 与 Has One/Has Many 相反,表示当前模型属于另一个模型。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// User 用户
type User struct {
ID uint
Name string
}
// Post 文章:属于某个用户
type Post struct {
ID uint
Title string
UserID uint // 外键
User User // belongs to
}
func main() {
db, err := gorm.Open(sqlite.Open("belongsto.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Post{})
// 创建用户
user := User{Name: "Tom"}
db.Create(&user)
// 创建属于该用户的文章
post := Post{Title: "Hello", UserID: user.ID}
db.Create(&post)
// 预加载 User
var found Post
db.Preload("User").First(&found, post.ID)
fmt.Printf("文章: %s, 作者: %s\n", found.Title, found.User.Name)
}多对多关系:Many2Many
Many2 Many 需要一个中间表来连接两个模型。GORM 自动创建中间表。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Post 文章
type Post struct {
ID uint
Title string
Tags []Tag `gorm:"many2many:post_tags;"` // 指定中间表名
}
// Tag 标签
type Tag struct {
ID uint
Name string
Posts []Post `gorm:"many2many:post_tags;"`
}
func main() {
db, err := gorm.Open(sqlite.Open("m2m.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Post{}, &Tag{})
// 创建标签
goTag := Tag{Name: "Go"}
db.Create(&goTag)
ormTag := Tag{Name: "ORM"}
db.Create(&ormTag)
// 创建文章并关联多个标签
post := Post{
Title: "GORM 教程",
Tags: []Tag{goTag, ormTag},
}
db.Create(&post)
// 预加载查询文章的所有标签
var foundPost Post
db.Preload("Tags").First(&foundPost, post.ID)
fmt.Printf("文章 %s 的标签:\n", foundPost.Title)
for _, t := range foundPost.Tags {
fmt.Printf(" - %s\n", t.Name)
}
// 反向预加载:查询标签的所有文章
var foundTag Tag
db.Preload("Posts").First(&foundTag, goTag.ID)
fmt.Printf("标签 %s 的文章:\n", foundTag.Name)
for _, p := range foundTag.Posts {
fmt.Printf(" - %s\n", p.Title)
}
}外键与引用
可以通过 foreignKey 和 references 标签自定义外键和引用字段:
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Company 公司
type Company struct {
ID uint
Name string
Code string `gorm:"uniqueIndex"` // 公司代码作为引用
}
// Employee 员工
type Employee struct {
ID uint
Name string
CompanyCode string // 自定义外键字段
Company Company `gorm:"foreignKey:CompanyCode;references:Code"`
}
func main() {
db, err := gorm.Open(sqlite.Open("fk.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Company{}, &Employee{})
company := Company{Name: "Acme", Code: "ACME001"}
db.Create(&company)
emp := Employee{Name: "Tom", CompanyCode: "ACME001"}
db.Create(&emp)
var found Employee
db.Preload("Company").First(&found, emp.ID)
fmt.Printf("员工 %s 所属公司: %s\n", found.Name, found.Company.Name)
}预加载 Preload
如果不使用预加载,访问关联字段会是空值。GORM 默认不加载关联数据,需要使用 Preload 主动加载,这是解决 N+1 查询问题的关键。
N+1 问题演示
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type User struct {
ID uint
Name string
Posts []Post
}
type Post struct {
ID uint
UserID uint
Title string
}
func main() {
db, err := gorm.Open(sqlite.Open("n1.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 打印 SQL
})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Post{})
// 准备数据
for i := 1; i <= 3; i++ {
user := User{Name: fmt.Sprintf("user_%d", i)}
db.Create(&user)
for j := 1; j <= 2; j++ {
db.Create(&Post{UserID: user.ID, Title: fmt.Sprintf("post_%d_%d", i, j)})
}
}
// 错误做法:N+1 查询
var users []User
db.Find(&users) // 1 次查询
for _, u := range users {
var posts []Post
db.Where("user_id = ?", u.ID).Find(&posts) // 每个用户 1 次查询
u.Posts = posts
}
fmt.Println("N+1 完成(执行了 1+N 次查询)")
// 正确做法:使用 Preload
var users2 []User
db.Preload("Posts").Find(&users2) // 只需 2 次查询
fmt.Printf("Preload 完成,共 %d 用户\n", len(users2))
for _, u := range users2 {
fmt.Printf(" %s: %d 篇文章\n", u.Name, len(u.Posts))
}
}嵌套预加载
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint
Name string
Posts []Post
}
type Post struct {
ID uint
UserID uint
Title string
Comments []Comment
}
type Comment struct {
ID uint
PostID uint
Body string
}
func main() {
db, err := gorm.Open(sqlite.Open("nested.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Post{}, &Comment{})
user := User{
Name: "Tom",
Posts: []Post{
{
Title: "文章1",
Comments: []Comment{
{Body: "评论1"},
{Body: "评论2"},
},
},
{Title: "文章2"},
},
}
db.Create(&user)
// 嵌套预加载:Posts -> Comments
var found User
db.Preload("Posts.Comments").First(&found, user.ID)
for _, p := range found.Posts {
fmt.Printf("文章: %s, 评论数: %d\n", p.Title, len(p.Comments))
}
}条件预加载
可以为 Preload 提供条件,只加载符合条件的关联:
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint
Name string
Posts []Post
}
type Post struct {
ID uint
UserID uint
Title string
ViewCount int
}
func main() {
db, err := gorm.Open(sqlite.Open("cond.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Post{})
user := User{Name: "Tom"}
db.Create(&user)
db.Create(&Post{UserID: user.ID, Title: "热门", ViewCount: 1000})
db.Create(&Post{UserID: user.ID, Title: "冷门", ViewCount: 5})
var found User
// 只预加载 ViewCount > 100 的文章
db.Preload("Posts", "view_count > ?", 100).First(&found, user.ID)
fmt.Printf("用户 %s 的热门文章:\n", found.Name)
for _, p := range found.Posts {
fmt.Printf(" - %s (浏览: %d)\n", p.Title, p.ViewCount)
}
// 自定义排序预加载
var found2 User
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Order("view_count DESC").Limit(1)
}).First(&found2, user.ID)
fmt.Printf("用户 %s 浏览量最高的文章:\n", found2.Name)
for _, p := range found2.Posts {
fmt.Printf(" - %s (浏览: %d)\n", p.Title, p.ViewCount)
}
}关联模式 Association API
GORM 提供了 Association 方法获取关联模式,可以对关联进行查找、添加、替换、删除、清空、计数等操作。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint
Name string
Roles []Role `gorm:"many2many:user_roles;"`
}
type Role struct {
ID uint
Name string
}
func main() {
db, err := gorm.Open(sqlite.Open("assoc.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Role{})
// 创建角色
admin := Role{Name: "admin"}
editor := Role{Name: "editor"}
viewer := Role{Name: "viewer"}
db.Create(&admin)
db.Create(&editor)
db.Create(&viewer)
// 创建用户并关联角色
user := User{Name: "Tom", Roles: []Role{admin, editor}}
db.Create(&user)
// 查找关联
var roles []Role
db.Model(&user).Association("Roles").Find(&roles)
fmt.Printf("用户 %s 的角色数: %d\n", user.Name,
db.Model(&user).Association("Roles").Count())
for _, r := range roles {
fmt.Printf(" - %s\n", r.Name)
}
// 添加关联
db.Model(&user).Association("Roles").Append(&viewer)
fmt.Printf("添加后角色数: %d\n",
db.Model(&user).Association("Roles").Count())
// 替换关联:清空原有,添加新的
db.Model(&user).Association("Roles").Replace(&admin)
fmt.Printf("替换后角色数: %d\n",
db.Model(&user).Association("Roles").Count())
// 删除关联(仅移除关联关系,不删除实体)
db.Model(&user).Association("Roles").Delete(&admin)
fmt.Printf("删除后角色数: %d\n",
db.Model(&user).Association("Roles").Count())
// 清空所有关联
db.Model(&user).Association("Roles").Clear()
fmt.Printf("清空后角色数: %d\n",
db.Model(&user).Association("Roles").Count())
}添加/替换/删除关联详解
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint
Name string
Languages []Language `gorm:"many2many:user_languages;"`
}
type Language struct {
ID uint
Name string
}
func main() {
db, err := gorm.Open(sqlite.Open("ops.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&User{}, &Language{})
go_ := Language{Name: "Go"}
py := Language{Name: "Python"}
rs := Language{Name: "Rust"}
js := Language{Name: "JavaScript"}
db.Create(&go_)
db.Create(&py)
db.Create(&rs)
db.Create(&js)
user := User{Name: "Tom", Languages: []Language{go_, py}}
db.Create(&user)
// Append:添加多个关联(已存在的不会重复)
db.Model(&user).Association("Languages").Append(&rs, &js)
fmt.Printf("Append 后语言数: %d\n",
db.Model(&user).Association("Languages").Count())
// Replace:完全替换关联
db.Model(&user).Association("Languages").Replace(&go_, &rs)
fmt.Printf("Replace 后语言数: %d\n",
db.Model(&user).Association("Languages").Count())
// Delete:删除关联(只是从中间表移除,不删除 Language 记录)
db.Model(&user).Association("Languages").Delete(&py)
fmt.Printf("Delete 后语言数: %d\n",
db.Model(&user).Association("Languages").Count())
// 验证 Language 表记录没被删除
var langCount int64
db.Model(&Language{}).Count(&langCount)
fmt.Printf("Language 表记录数: %d(未被删除)\n", langCount)
// Clear:清空所有关联
db.Model(&user).Association("Languages").Clear()
fmt.Printf("Clear 后语言数: %d\n",
db.Model(&user).Association("Languages").Count())
}多态关联
多态关联允许一个模型同时属于多种类型的模型。例如评论可以属于文章、视频、图片等。
go
package main
import (
"fmt"
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Comment 评论模型:通过多态关联属于不同模型
type Comment struct {
ID uint
Body string
CommentableID uint // 关联对象 ID
CommentableType string // 关联对象类型:post / video
Post *Post `gorm:"polymorphic:Commentable;"`
Video *Video `gorm:"polymorphic:Commentable;"`
}
// Post 文章
type Post struct {
ID uint
Title string
Comments []Comment `gorm:"polymorphic:Commentable;"`
}
// Video 视频
type Video struct {
ID uint
Title string
Comments []Comment `gorm:"polymorphic:Commentable;"`
}
func main() {
db, err := gorm.Open(sqlite.Open("poly.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Post{}, &Video{}, &Comment{})
// 创建文章并关联评论
post := Post{
Title: "Go 教程",
Comments: []Comment{
{Body: "很棒!"},
{Body: "学到了"},
},
}
db.Create(&post)
// 创建视频并关联评论
video := Video{
Title: "GORM 视频",
Comments: []Comment{
{Body: "讲得清楚"},
},
}
db.Create(&video)
// 预加载查询文章的所有评论
var foundPost Post
db.Preload("Comments").First(&foundPost, post.ID)
fmt.Printf("文章 %s 的评论:\n", foundPost.Title)
for _, c := range foundPost.Comments {
fmt.Printf(" - %s (类型=%s, ID=%d)\n",
c.Body, c.CommentableType, c.CommentableID)
}
// 预加载查询视频的评论
var foundVideo Video
db.Preload("Comments").First(&foundVideo, video.ID)
fmt.Printf("视频 %s 的评论:\n", foundVideo.Title)
for _, c := range foundVideo.Comments {
fmt.Printf(" - %s (类型=%s, ID=%d)\n",
c.Body, c.CommentableType, c.CommentableID)
}
}完整示例:电商系统模型
下面用一个电商系统模型综合演示各种关联关系,包含分类、商品、标签、图片等。
go
package main
import (
"fmt"
"log"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Category 商品分类(自关联:一对多)
type Category struct {
ID uint `gorm:"primaryKey"`
Name string
ParentID *uint // 父分类 ID,可为空
Parent *Category `gorm:"foreignKey:ParentID"` // 父分类
Children []Category `gorm:"foreignKey:ParentID"` // 子分类
Products []Product // 分类下的商品
}
// Product 商品
type Product struct {
ID uint `gorm:"primaryKey"`
Name string
Price float64
CategoryID uint
Category Category `gorm:"foreignKey:CategoryID"` // 属于某个分类
Tags []Tag `gorm:"many2many:product_tags;"` // 多对多标签
Images []Image `gorm:"polymorphic:Imageable;"` // 多态:商品图片
CreatedAt time.Time
}
// Tag 标签
type Tag struct {
ID uint
Name string
Products []Product `gorm:"many2many:product_tags;"`
}
// Image 图片(多态:可属于 Product 或其他模型)
type Image struct {
ID uint
URL string
ImageableID uint
ImageableType string
}
func main() {
db, err := gorm.Open(sqlite.Open("ecommerce.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Category{}, &Product{}, &Tag{}, &Image{})
// 创建分类层级
electronics := Category{Name: "电子产品"}
db.Create(&electronics)
phones := Category{Name: "手机", ParentID: &electronics.ID}
db.Create(&phones)
// 创建标签
goTag := Tag{Name: "热销"}
newTag := Tag{Name: "新品"}
db.Create(&goTag)
db.Create(&newTag)
// 创建商品并关联分类、标签、图片
product := Product{
Name: "iPhone 15",
Price: 7999,
CategoryID: phones.ID,
Tags: []Tag{goTag, newTag},
Images: []Image{
{URL: "iphone_front.jpg"},
{URL: "iphone_back.jpg"},
},
}
if err := db.Create(&product).Error; err != nil {
log.Fatal(err)
}
fmt.Printf("商品创建成功: %s (id=%d)\n", product.Name, product.ID)
// 综合查询:商品 + 分类 + 标签 + 图片
var fullProduct Product
if err := db.
Preload("Category").
Preload("Category.Parent").
Preload("Tags").
Preload("Images").
First(&fullProduct, product.ID).Error; err != nil {
log.Fatal(err)
}
fmt.Println("\n=== 商品详情 ===")
fmt.Printf("名称: %s\n", fullProduct.Name)
fmt.Printf("价格: %.2f\n", fullProduct.Price)
fmt.Printf("分类: %s\n", fullProduct.Category.Name)
if fullProduct.Category.Parent != nil {
fmt.Printf("父分类: %s\n", fullProduct.Category.Parent.Name)
}
fmt.Printf("标签: ")
for _, t := range fullProduct.Tags {
fmt.Printf("%s ", t.Name)
}
fmt.Println()
fmt.Printf("图片: ")
for _, img := range fullProduct.Images {
fmt.Printf("%s ", img.URL)
}
fmt.Println()
// 查询分类下的所有商品
var category Category
db.Preload("Products").First(&category, phones.ID)
fmt.Printf("\n分类 %s 下的商品: %d 个\n", category.Name, len(category.Products))
// 查询标签关联的所有商品
var tag Tag
db.Preload("Products").First(&tag, goTag.ID)
fmt.Printf("标签 %s 关联商品: %d 个\n", tag.Name, len(tag.Products))
// 使用 Association 添加新标签
discountTag := Tag{Name: "折扣"}
db.Create(&discountTag)
db.Model(&fullProduct).Association("Tags").Append(&discountTag)
fmt.Printf("\n添加折扣标签后商品标签数: %d\n",
db.Model(&fullProduct).Association("Tags").Count())
}小结
本篇系统讲解了 GORM 的关联关系:
- Has One(一对一):一个模型拥有另一个模型,外键在被拥有方
- Has Many(一对多):一个模型拥有多个其他模型
- Belongs To(属于):当前模型引用另一个模型作为外键
- Many2Many(多对多):通过中间表连接两个模型
- 外键与引用:
foreignKey和references标签自定义映射 - Preload 预加载:解决 N+1 查询问题,支持嵌套和条件预加载
- Association API:查找、添加、替换、删除、清空关联
- 多态关联:一个模型属于多种类型的模型
下一篇我们将学习事务与 Hook 钩子机制,掌握数据一致性与生命周期回调。